Version 1.2.7: Fix API client health monitoring and add betting mode configuration

- Fixed 'Unhealthy components detected: api_client' warning by implementing comprehensive heartbeat system
- Added heartbeat calls in main loop, HTTP requests, database operations, and error handling
- Added betting mode configuration UI in web dashboard with database persistence
- Created Migration_015_AddBettingModeTable for betting_modes table
- Added betting mode API endpoints (GET/POST /api/betting-mode)
- Updated documentation with betting mode configuration and API client health monitoring
- Enhanced error handling to prevent thread hangs and maintain health status
- Added troubleshooting section for API client health issues
parent 4ddf7439
...@@ -2,6 +2,34 @@ ...@@ -2,6 +2,34 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
## [1.2.7] - 2025-08-26
### Added
- **Betting Mode Configuration**: Complete betting mode management system with web dashboard integration
- **Database Migration System**: Migration_015_AddBettingModeTable for betting_modes table with proper schema
- **Betting Mode API**: RESTful endpoints for getting and setting user betting modes
- **Configuration UI Enhancement**: Added betting mode settings section to configuration page
### Fixed
- **API Client Health Monitoring**: Resolved "Unhealthy components detected: ['api_client']" warning
- **Heartbeat System**: Implemented comprehensive heartbeat updates throughout API client operations
- **Thread Health Checks**: Enhanced error handling to prevent thread hangs and maintain health status
- **Database Operation Protection**: Added heartbeat calls during potentially slow database operations
- **HTTP Request Protection**: Added heartbeat calls before and after HTTP requests to prevent timeouts
### Enhanced
- **Error Handling**: Improved API client error handling with heartbeat updates even during failures
- **Logging**: Added debug logging for heartbeat updates during error handling
- **Performance**: Optimized heartbeat system to minimize overhead while ensuring health monitoring
- **Configuration Persistence**: Enhanced betting mode configuration with database persistence
### Technical Details
- **API Client Heartbeat**: Added heartbeat calls in main loop, HTTP requests, database operations, and error handling
- **Betting Mode Model**: Created BettingModeModel with user-specific settings and database constraints
- **Migration System**: Added Migration_015 with proper SQLite schema and indexing
- **Web Dashboard Integration**: Complete betting mode UI with real-time feedback and validation
- **Cross-Platform Compatibility**: Betting mode system works consistently across all supported platforms
## [1.2.6] - 2025-08-26 ## [1.2.6] - 2025-08-26
### Added ### Added
......
...@@ -160,6 +160,33 @@ Edit configuration through the web dashboard or modify JSON files directly: ...@@ -160,6 +160,33 @@ Edit configuration through the web dashboard or modify JSON files directly:
} }
``` ```
### Betting Mode Configuration
The application supports two betting modes that can be configured per user:
#### Betting Modes
- **All Bets on Start**: Place all bets simultaneously when games begin (default)
- **One Bet at a Time**: Place bets individually for more controlled betting
#### Configuration
Betting mode is configured through the web dashboard:
1. Navigate to Configuration → Betting Mode Settings
2. Select desired betting mode from dropdown
3. Click "Save Betting Mode"
4. Settings are saved per user and persist across sessions
#### API Configuration
```json
{
"betting_mode": {
"user_id": 1,
"mode": "all_bets_on_start",
"created_at": "2025-08-26T11:30:00Z",
"updated_at": "2025-08-26T11:30:00Z"
}
}
```
### Environment Variables ### Environment Variables
Create a `.env` file in the project root: Create a `.env` file in the project root:
...@@ -689,6 +716,43 @@ Content-Type: application/json ...@@ -689,6 +716,43 @@ Content-Type: application/json
} }
``` ```
#### Get Betting Mode
```http
GET /api/betting-mode
Authorization: Bearer <token>
```
**Response:**
```json
{
"success": true,
"betting_mode": "all_bets_on_start",
"user_id": 1
}
```
#### Update Betting Mode
```http
POST /api/betting-mode
Authorization: Bearer <token>
Content-Type: application/json
{
"betting_mode": "one_bet_at_a_time"
}
```
**Response:**
```json
{
"success": true,
"message": "Betting mode updated successfully",
"betting_mode": "one_bet_at_a_time"
}
```
## Match Timer System ## Match Timer System
The application includes a comprehensive match timer system with automatic match progression, visual countdown displays, and command-line timer configuration. The application includes a comprehensive match timer system with automatic match progression, visual countdown displays, and command-line timer configuration.
...@@ -978,6 +1042,18 @@ This message is processed by the games thread to change the match status from "b ...@@ -978,6 +1042,18 @@ This message is processed by the games thread to change the match status from "b
4. Ensure proper file permissions on database file 4. Ensure proper file permissions on database file
5. Verify SQLite installation and compatibility 5. Verify SQLite installation and compatibility
#### API Client Health Issues
**Symptoms**: "Unhealthy components detected: ['api_client']" warning in logs
**Solutions**:
1. Check API client thread is running and not blocked
2. Verify network connectivity for API endpoints
3. Check API token validity and expiration
4. Review API client logs for specific error messages
5. Ensure database operations are not causing long delays
6. Check for HTTP request timeouts or network issues
7. Verify heartbeat system is functioning (added in version 1.2.7)
#### Performance Issues #### Performance Issues
**Symptoms**: Interface slow, updates delayed **Symptoms**: Interface slow, updates delayed
......
...@@ -24,6 +24,14 @@ A cross-platform multimedia client application with video playback, web dashboar ...@@ -24,6 +24,14 @@ A cross-platform multimedia client application with video playback, web dashboar
## Recent Improvements ## Recent Improvements
### Version 1.2.7 (August 2025)
-**API Client Health Monitoring**: Fixed "Unhealthy components detected: ['api_client']" warning by implementing comprehensive heartbeat system
-**Betting Mode Configuration**: Added complete betting mode management in web dashboard with database persistence
-**Database Migration System**: Added Migration_015 for betting_modes table with proper schema and constraints
-**Enhanced Error Handling**: Improved API client error handling to prevent thread hangs and maintain health status
-**Configuration UI Enhancement**: Added betting mode settings section to configuration page with real-time feedback
### Version 1.2.6 (August 2025) ### Version 1.2.6 (August 2025)
-**Automated Game Timer**: New `--start-timer` command line switch that sends START_GAME message after specified delay (default 4 minutes) -**Automated Game Timer**: New `--start-timer` command line switch that sends START_GAME message after specified delay (default 4 minutes)
...@@ -168,6 +176,9 @@ python main.py --start-timer ...@@ -168,6 +176,9 @@ python main.py --start-timer
# Start with custom timer delay (2 minutes) # Start with custom timer delay (2 minutes)
python main.py --start-timer 2 python main.py --start-timer 2
# Enable debug mode showing only message bus messages
python main.py --dev-message
# Show help # Show help
python main.py --help python main.py --help
``` ```
...@@ -192,6 +203,7 @@ Configuration is stored in SQLite database with automatic versioning. Access the ...@@ -192,6 +203,7 @@ Configuration is stored in SQLite database with automatic versioning. Access the
- System settings - System settings
- API token management - API token management
- User account management - User account management
- Betting mode configuration (all bets on start vs one bet at a time)
### Default Login ### Default Login
...@@ -270,6 +282,10 @@ Threads communicate via Python Queues with structured messages: ...@@ -270,6 +282,10 @@ Threads communicate via Python Queues with structured messages:
- `PUT /api/config/{section}` - Update configuration section - `PUT /api/config/{section}` - Update configuration section
- `GET /api/config/{section}` - Get specific configuration section - `GET /api/config/{section}` - Get specific configuration section
#### Betting Mode Management
- `GET /api/betting-mode` - Get current betting mode for user
- `POST /api/betting-mode` - Update betting mode for user
#### Template Management #### Template Management
- `GET /api/templates` - List available overlay templates - `GET /api/templates` - List available overlay templates
- `POST /api/templates/upload` - Upload new template file - `POST /api/templates/upload` - Upload new template file
...@@ -348,6 +364,11 @@ Threads communicate via Python Queues with structured messages: ...@@ -348,6 +364,11 @@ Threads communicate via Python Queues with structured messages:
**Database errors during operation** **Database errors during operation**
- Fixed in version 1.1 - all database operations now properly handle session closure - Fixed in version 1.1 - all database operations now properly handle session closure
**API client showing as unhealthy**
- Fixed in version 1.2.7 - enhanced heartbeat system prevents "Unhealthy components detected: ['api_client']" warnings
- API client now maintains regular heartbeat updates even during long-running operations
- Improved error handling prevents thread hangs that could cause health check failures
### Building Issues ### Building Issues
**PyInstaller build fails with missing modules** **PyInstaller build fails with missing modules**
......
...@@ -475,7 +475,7 @@ The application stores its configuration and database in: ...@@ -475,7 +475,7 @@ The application stores its configuration and database in:
## Web Interface ## Web Interface
By default, the web interface is available at: http://localhost:5000 By default, the web interface is available at: http://localhost:5001
Default login credentials: Default login credentials:
- Username: admin - Username: admin
......
# Netscape HTTP Cookie File
# https://curl.se/docs/http-cookies.html
# This file was generated by libcurl! Edit at your own risk.
#HttpOnly_127.0.0.1 FALSE / FALSE 0 session .eJwlzj0OwjAMQOG7ZGaof2LHvUyVxLZgTemEuDuV2N-Tvk85csX5LPt7XfEox8vLXoIZkY2FESapD3Yga6kIHNw6JUnidGftW6ZynVul6BGm5lBTEkzlrprNmB6OTdQbior3SjrqmDxti_tRkQ5JqTCEOKyjlBtynbH-GizfH6DyL20.aKzyUQ.JbC1Zst6dLOPb6oWBcnMziVCKFk
#HttpOnly_127.0.0.1 FALSE / FALSE 1787700689 remember_token 2|6839ea0e127e93742c1d52ba7c14f92c9b5b4e4a2fe97bb74c5b862f79ef1ce97a0297989c74f951dd7eba7340fd23ec4cb5e25a37c3dc28d53f13921f5f0776
#!/usr/bin/env python3
"""
Script to create test matches for the cashier interface
"""
import sys
from pathlib import Path
from datetime import datetime, date, timedelta
# Add the project root to Python path
project_root = Path(__file__).parent
sys.path.insert(0, str(project_root))
from mbetterclient.database.models import MatchModel
from mbetterclient.database.manager import DatabaseManager
from mbetterclient.config.settings import get_user_data_dir
def create_test_matches():
"""Create some test matches for the cashier interface"""
# Use the default database path
db_path = get_user_data_dir() / "mbetterclient.db"
db_manager = DatabaseManager(str(db_path))
if not db_manager.initialize():
print("Failed to initialize database")
return False
session = db_manager.get_session()
try:
# Check if matches already exist
existing_count = session.query(MatchModel).count()
if existing_count > 0:
print(f"Database already has {existing_count} matches. Skipping creation.")
return True
# Create test matches for today
today = date.today()
now = datetime.now()
# Create matches for fixture 1 (today's matches)
fixture_1_matches = [
MatchModel(
fixture_id=1,
match_number=1,
fighter1_township="Kampala Central",
fighter2_township="Nakawa",
venue_kampala_township="Main Arena",
start_time=datetime.combine(today, datetime.min.time()) + timedelta(hours=10),
status="pending",
created_at=now - timedelta(days=1)
),
MatchModel(
fixture_id=1,
match_number=2,
fighter1_township="Makindye",
fighter2_township="Rubaga",
venue_kampala_township="Main Arena",
start_time=datetime.combine(today, datetime.min.time()) + timedelta(hours=11),
status="pending",
created_at=now - timedelta(days=1)
),
MatchModel(
fixture_id=1,
match_number=3,
fighter1_township="Kawempe",
fighter2_township="Wandegeya",
venue_kampala_township="Secondary Arena",
start_time=datetime.combine(today, datetime.min.time()) + timedelta(hours=14),
status="pending",
created_at=now - timedelta(days=1)
),
MatchModel(
fixture_id=1,
match_number=4,
fighter1_township="Ntinda",
fighter2_township="Kololo",
venue_kampala_township="Main Arena",
start_time=datetime.combine(today, datetime.min.time()) + timedelta(hours=15),
status="pending",
created_at=now - timedelta(days=1)
),
MatchModel(
fixture_id=1,
match_number=5,
fighter1_township="Lubaga",
fighter2_township="Mengo",
venue_kampala_township="Secondary Arena",
start_time=datetime.combine(today, datetime.min.time()) + timedelta(hours=16),
status="pending",
created_at=now - timedelta(days=1)
)
]
# Create matches for fixture 2 (tomorrow's matches)
tomorrow = today + timedelta(days=1)
fixture_2_matches = [
MatchModel(
fixture_id=2,
match_number=1,
fighter1_township="Entebbe",
fighter2_township="Mukono",
venue_kampala_township="Main Arena",
start_time=datetime.combine(tomorrow, datetime.min.time()) + timedelta(hours=10),
status="scheduled",
created_at=now - timedelta(days=1)
),
MatchModel(
fixture_id=2,
match_number=2,
fighter1_township="Jinja",
fighter2_township="Iganga",
venue_kampala_township="Secondary Arena",
start_time=datetime.combine(tomorrow, datetime.min.time()) + timedelta(hours=11),
status="scheduled",
created_at=now - timedelta(days=1)
)
]
# Add all matches to the session
all_matches = fixture_1_matches + fixture_2_matches
for match in all_matches:
session.add(match)
# Commit the changes
session.commit()
print(f"Successfully created {len(all_matches)} test matches:")
print(f" - Fixture 1: {len(fixture_1_matches)} matches (today)")
print(f" - Fixture 2: {len(fixture_2_matches)} matches (tomorrow)")
# Verify the matches were created
total_count = session.query(MatchModel).count()
print(f"Total matches in database: {total_count}")
return True
except Exception as e:
print(f"Error creating test matches: {e}")
session.rollback()
return False
finally:
session.close()
db_manager.close()
if __name__ == "__main__":
print("Creating test matches for cashier interface...")
success = create_test_matches()
if success:
print("\n✅ Test matches created successfully!")
print("The cashier interface should now display pending matches.")
else:
print("\n❌ Failed to create test matches.")
sys.exit(1)
\ No newline at end of file
import re
with open('mbetterclient/web_dashboard/templates/dashboard/fixtures.html', 'r') as f:
content = f.read()
# Extract JavaScript
js_matches = re.findall(r'<script[^>]*>(.*?)</script>', content, re.DOTALL)
if js_matches:
js_code = js_matches[0]
# Write to a temporary file to examine
with open('temp_js.js', 'w') as f:
f.write(js_code)
print("JavaScript extracted and saved to temp_js.js")
print("First 200 characters:")
print(js_code[:200])
print("\nLast 200 characters:")
print(js_code[-200:])
else:
print("No JavaScript found")
\ No newline at end of file
...@@ -41,7 +41,7 @@ def setup_signal_handlers(app): ...@@ -41,7 +41,7 @@ def setup_signal_handlers(app):
except ImportError: except ImportError:
pass # Qt not available pass # Qt not available
except Exception as e: except Exception as e:
logging.debug(f"Qt shutdown check failed: {e}") logging.debug("Qt shutdown check failed: {}".format(e))
# Fallback to normal app shutdown if Qt not running # Fallback to normal app shutdown if Qt not running
if app: if app:
...@@ -74,6 +74,7 @@ Examples: ...@@ -74,6 +74,7 @@ Examples:
python main.py --no-fullscreen # Run in windowed mode python main.py --no-fullscreen # Run in windowed mode
python main.py --web-port 8080 # Custom web dashboard port python main.py --web-port 8080 # Custom web dashboard port
python main.py --debug # Enable debug logging python main.py --debug # Enable debug logging
python main.py --dev-message # Show only message bus messages
""" """
) )
...@@ -126,6 +127,12 @@ Examples: ...@@ -126,6 +127,12 @@ Examples:
action='store_true', action='store_true',
help='Enable development mode with additional debugging' help='Enable development mode with additional debugging'
) )
parser.add_argument(
'--dev-message',
action='store_true',
help='Enable debug mode showing only message bus messages'
)
parser.add_argument( parser.add_argument(
'--no-qt', '--no-qt',
...@@ -214,7 +221,7 @@ def main(): ...@@ -214,7 +221,7 @@ def main():
logger.info("=" * 60) logger.info("=" * 60)
logger.info("MbetterClient Starting") logger.info("MbetterClient Starting")
logger.info("=" * 60) logger.info("=" * 60)
logger.info(f"Arguments: {vars(args)}") logger.info("Arguments: {}".format(vars(args)))
# Create application settings # Create application settings
settings = AppSettings() settings = AppSettings()
...@@ -222,6 +229,7 @@ def main(): ...@@ -222,6 +229,7 @@ def main():
settings.web_host = args.web_host settings.web_host = args.web_host
settings.web_port = args.web_port settings.web_port = args.web_port
settings.debug_mode = args.debug or args.dev_mode settings.debug_mode = args.debug or args.dev_mode
settings.dev_message = args.dev_message
settings.enable_qt = not args.no_qt settings.enable_qt = not args.no_qt
settings.enable_web = not args.no_web settings.enable_web = not args.no_web
settings.qt.use_native_overlay = args.native_overlay settings.qt.use_native_overlay = args.native_overlay
...@@ -248,7 +256,7 @@ def main(): ...@@ -248,7 +256,7 @@ def main():
settings.database_path = args.db_path settings.database_path = args.db_path
# Create and initialize application # Create and initialize application
app = MbetterClientApplication(settings) app = MbetterClientApplication(settings, start_timer=args.start_timer)
# Setup signal handlers for graceful shutdown # Setup signal handlers for graceful shutdown
setup_signal_handlers(app) setup_signal_handlers(app)
...@@ -270,7 +278,7 @@ def main(): ...@@ -270,7 +278,7 @@ def main():
print("\nInterrupted by user") print("\nInterrupted by user")
sys.exit(0) sys.exit(0)
except Exception as e: except Exception as e:
print(f"Fatal error: {e}") print("Fatal error: {}".format(e))
if args.debug if 'args' in locals() else False: if args.debug if 'args' in locals() else False:
import traceback import traceback
traceback.print_exc() traceback.print_exc()
......
...@@ -241,6 +241,10 @@ class UpdatesResponseHandler(ResponseHandler): ...@@ -241,6 +241,10 @@ class UpdatesResponseHandler(ResponseHandler):
matches = fixture_data.get('matches', []) matches = fixture_data.get('matches', [])
for match_data in matches: for match_data in matches:
try: try:
# Update heartbeat during processing to prevent health check failures
if self.api_client:
self.api_client.heartbeat()
# Add fixture-level data to match if available # Add fixture-level data to match if available
if 'fixture_id' in fixture_data: if 'fixture_id' in fixture_data:
match_data['fixture_id'] = fixture_data['fixture_id'] match_data['fixture_id'] = fixture_data['fixture_id']
...@@ -264,6 +268,10 @@ class UpdatesResponseHandler(ResponseHandler): ...@@ -264,6 +268,10 @@ class UpdatesResponseHandler(ResponseHandler):
# Continue processing other matches even if this one fails # Continue processing other matches even if this one fails
continue continue
# Update heartbeat before potentially slow commit operation
if self.api_client:
self.api_client.heartbeat()
session.commit() session.commit()
finally: finally:
...@@ -381,9 +389,16 @@ class UpdatesResponseHandler(ResponseHandler): ...@@ -381,9 +389,16 @@ class UpdatesResponseHandler(ResponseHandler):
# Save to persistent storage # Save to persistent storage
zip_path = self.zip_storage_dir / zip_filename zip_path = self.zip_storage_dir / zip_filename
# Update heartbeat before potentially slow file write
if self.api_client:
self.api_client.heartbeat()
with open(zip_path, 'wb') as f: with open(zip_path, 'wb') as f:
for chunk in response.iter_content(chunk_size=8192): for chunk in response.iter_content(chunk_size=8192):
f.write(chunk) f.write(chunk)
# Update heartbeat every few chunks for large files
if self.api_client and f.tell() % (1024 * 1024) == 0: # Every MB
self.api_client.heartbeat()
logger.info(f"Downloaded ZIP file: {zip_filename}") logger.info(f"Downloaded ZIP file: {zip_filename}")
return True return True
...@@ -618,21 +633,29 @@ class APIClient(ThreadedComponent): ...@@ -618,21 +633,29 @@ class APIClient(ThreadedComponent):
# Main execution loop # Main execution loop
while self.running: while self.running:
try: try:
# Update heartbeat at the start of each loop
self.heartbeat()
# Process messages # Process messages
message = self.message_bus.get_message(self.name, timeout=1.0) message = self.message_bus.get_message(self.name, timeout=1.0)
if message: if message:
self._process_message(message) self._process_message(message)
# Update heartbeat before potentially long operations
self.heartbeat()
# Execute scheduled API requests # Execute scheduled API requests
self._execute_scheduled_requests() self._execute_scheduled_requests()
# Update heartbeat # Update heartbeat after operations
self.heartbeat() self.heartbeat()
time.sleep(1.0) time.sleep(1.0)
except Exception as e: except Exception as e:
logger.error(f"APIClient run loop error: {e}") logger.error(f"APIClient run loop error: {e}")
# Update heartbeat even in error cases
self.heartbeat()
time.sleep(5.0) time.sleep(5.0)
except Exception as e: except Exception as e:
...@@ -649,6 +672,9 @@ class APIClient(ThreadedComponent): ...@@ -649,6 +672,9 @@ class APIClient(ThreadedComponent):
def _get_last_fixture_timestamp(self) -> Optional[str]: def _get_last_fixture_timestamp(self) -> Optional[str]:
"""Get the server activation timestamp of the last active fixture in the database""" """Get the server activation timestamp of the last active fixture in the database"""
try: try:
# Update heartbeat before database operation
self.heartbeat()
session = self.db_manager.get_session() session = self.db_manager.get_session()
try: try:
# Get the most recent match with fixture_active_time set # Get the most recent match with fixture_active_time set
...@@ -656,6 +682,9 @@ class APIClient(ThreadedComponent): ...@@ -656,6 +682,9 @@ class APIClient(ThreadedComponent):
MatchModel.fixture_active_time.isnot(None) MatchModel.fixture_active_time.isnot(None)
).order_by(MatchModel.fixture_active_time.desc()).first() ).order_by(MatchModel.fixture_active_time.desc()).first()
# Update heartbeat after database query
self.heartbeat()
if last_active_match and last_active_match.fixture_active_time: if last_active_match and last_active_match.fixture_active_time:
# Return Unix timestamp as string (long integer number) # Return Unix timestamp as string (long integer number)
return str(last_active_match.fixture_active_time) return str(last_active_match.fixture_active_time)
...@@ -668,11 +697,16 @@ class APIClient(ThreadedComponent): ...@@ -668,11 +697,16 @@ class APIClient(ThreadedComponent):
except Exception as e: except Exception as e:
logger.error(f"Failed to get last fixture activation timestamp: {e}") logger.error(f"Failed to get last fixture activation timestamp: {e}")
# Update heartbeat even on error
self.heartbeat()
return None return None
def _execute_endpoint_request(self, endpoint: APIEndpoint): def _execute_endpoint_request(self, endpoint: APIEndpoint):
"""Execute a single API request with custom retry logic for token-based endpoints""" """Execute a single API request with custom retry logic for token-based endpoints"""
try: try:
# Update heartbeat before starting potentially long operation
self.heartbeat()
endpoint.last_request = datetime.utcnow() endpoint.last_request = datetime.utcnow()
endpoint.total_requests += 1 endpoint.total_requests += 1
self.stats['total_requests'] += 1 self.stats['total_requests'] += 1
...@@ -746,6 +780,9 @@ class APIClient(ThreadedComponent): ...@@ -746,6 +780,9 @@ class APIClient(ThreadedComponent):
response = self.session.request(**request_kwargs) response = self.session.request(**request_kwargs)
response.raise_for_status() response.raise_for_status()
# Update heartbeat after HTTP request completes
self.heartbeat()
# Debug log the complete response # Debug log the complete response
logger.debug(f"Response status code: {response.status_code}") logger.debug(f"Response status code: {response.status_code}")
logger.debug(f"Response headers: {dict(response.headers)}") logger.debug(f"Response headers: {dict(response.headers)}")
...@@ -762,6 +799,9 @@ class APIClient(ThreadedComponent): ...@@ -762,6 +799,9 @@ class APIClient(ThreadedComponent):
handler = self.response_handlers.get(endpoint.response_handler, self.response_handlers['default']) handler = self.response_handlers.get(endpoint.response_handler, self.response_handlers['default'])
processed_data = handler.handle_response(endpoint, response) processed_data = handler.handle_response(endpoint, response)
# Update heartbeat after response processing
self.heartbeat()
# Update endpoint status # Update endpoint status
endpoint.last_success = datetime.utcnow() endpoint.last_success = datetime.utcnow()
endpoint.last_error = None endpoint.last_error = None
...@@ -787,6 +827,7 @@ class APIClient(ThreadedComponent): ...@@ -787,6 +827,7 @@ class APIClient(ThreadedComponent):
except Exception as e: except Exception as e:
# Handle request failure # Handle request failure
self.heartbeat() # Update heartbeat even on failure
self._handle_request_failure(endpoint, e) self._handle_request_failure(endpoint, e)
def _execute_with_custom_retry(self, endpoint: APIEndpoint, request_kwargs: dict) -> bool: def _execute_with_custom_retry(self, endpoint: APIEndpoint, request_kwargs: dict) -> bool:
...@@ -801,6 +842,9 @@ class APIClient(ThreadedComponent): ...@@ -801,6 +842,9 @@ class APIClient(ThreadedComponent):
response = self.session.request(**request_kwargs) response = self.session.request(**request_kwargs)
response.raise_for_status() response.raise_for_status()
# Update heartbeat after HTTP retry request completes
self.heartbeat()
# Debug log the complete response (retry scenario) # Debug log the complete response (retry scenario)
logger.debug(f"Retry response status code: {response.status_code}") logger.debug(f"Retry response status code: {response.status_code}")
logger.debug(f"Retry response headers: {dict(response.headers)}") logger.debug(f"Retry response headers: {dict(response.headers)}")
...@@ -817,6 +861,9 @@ class APIClient(ThreadedComponent): ...@@ -817,6 +861,9 @@ class APIClient(ThreadedComponent):
handler = self.response_handlers.get(endpoint.response_handler, self.response_handlers['default']) handler = self.response_handlers.get(endpoint.response_handler, self.response_handlers['default'])
processed_data = handler.handle_response(endpoint, response) processed_data = handler.handle_response(endpoint, response)
# Update heartbeat after response processing
self.heartbeat()
# Update endpoint status - success! # Update endpoint status - success!
endpoint.last_success = datetime.utcnow() endpoint.last_success = datetime.utcnow()
endpoint.last_error = None endpoint.last_error = None
...@@ -849,7 +896,13 @@ class APIClient(ThreadedComponent): ...@@ -849,7 +896,13 @@ class APIClient(ThreadedComponent):
if attempt < max_retries - 1: if attempt < max_retries - 1:
logger.warning(f"API retry {attempt + 1} failed for {endpoint.name}: {e}. Waiting {retry_delay}s before next retry.") logger.warning(f"API retry {attempt + 1} failed for {endpoint.name}: {e}. Waiting {retry_delay}s before next retry.")
time.sleep(retry_delay) # Sleep in smaller chunks to allow heartbeat updates during long delays
remaining_delay = retry_delay
while remaining_delay > 0 and self.running:
sleep_chunk = min(5.0, remaining_delay) # Sleep in 5-second chunks
time.sleep(sleep_chunk)
self.heartbeat() # Update heartbeat during retry delays
remaining_delay -= sleep_chunk
else: else:
logger.error(f"All {max_retries} retries failed for {endpoint.name}: {e}") logger.error(f"All {max_retries} retries failed for {endpoint.name}: {e}")
...@@ -859,12 +912,18 @@ class APIClient(ThreadedComponent): ...@@ -859,12 +912,18 @@ class APIClient(ThreadedComponent):
def _handle_request_failure(self, endpoint: APIEndpoint, error: Exception): def _handle_request_failure(self, endpoint: APIEndpoint, error: Exception):
"""Handle request failure and send error message""" """Handle request failure and send error message"""
# Update heartbeat when handling failures
self.heartbeat()
endpoint.last_error = str(error) endpoint.last_error = str(error)
endpoint.consecutive_failures += 1 endpoint.consecutive_failures += 1
self.stats['failed_requests'] += 1 self.stats['failed_requests'] += 1
logger.error(f"API request failed: {endpoint.name} - {error}") logger.error(f"API request failed: {endpoint.name} - {error}")
# Log heartbeat status for debugging
logger.debug(f"API client heartbeat updated during error handling for {endpoint.name}")
# Send error message # Send error message
error_message = Message( error_message = Message(
type=MessageType.API_RESPONSE, type=MessageType.API_RESPONSE,
......
...@@ -22,10 +22,13 @@ logger = logging.getLogger(__name__) ...@@ -22,10 +22,13 @@ logger = logging.getLogger(__name__)
class MbetterClientApplication: class MbetterClientApplication:
"""Main application class that coordinates all components""" """Main application class that coordinates all components"""
def __init__(self, settings: AppSettings): def __init__(self, settings: AppSettings, start_timer: Optional[int] = None):
self.settings = settings self.settings = settings
self.running = False self.running = False
self.shutdown_event = threading.Event() self.shutdown_event = threading.Event()
# Store the command line start_timer setting separately (not affected by database override)
self._start_timer_minutes = start_timer
# Core components # Core components
self.db_manager: Optional[DatabaseManager] = None self.db_manager: Optional[DatabaseManager] = None
...@@ -146,7 +149,7 @@ class MbetterClientApplication: ...@@ -146,7 +149,7 @@ class MbetterClientApplication:
def _initialize_message_bus(self) -> bool: def _initialize_message_bus(self) -> bool:
"""Initialize message bus""" """Initialize message bus"""
try: try:
self.message_bus = MessageBus(max_queue_size=1000) self.message_bus = MessageBus(max_queue_size=1000, dev_message=self.settings.dev_message)
# Register core component # Register core component
self.message_bus.register_component("core") self.message_bus.register_component("core")
...@@ -155,7 +158,6 @@ class MbetterClientApplication: ...@@ -155,7 +158,6 @@ class MbetterClientApplication:
self.message_bus.subscribe("core", MessageType.SYSTEM_SHUTDOWN, self._handle_shutdown_message) self.message_bus.subscribe("core", MessageType.SYSTEM_SHUTDOWN, self._handle_shutdown_message)
self.message_bus.subscribe("core", MessageType.CONFIG_UPDATE, self._handle_config_update) self.message_bus.subscribe("core", MessageType.CONFIG_UPDATE, self._handle_config_update)
self.message_bus.subscribe("core", MessageType.LOG_ENTRY, self._handle_log_entry) self.message_bus.subscribe("core", MessageType.LOG_ENTRY, self._handle_log_entry)
self.message_bus.subscribe("core", MessageType.START_GAME, self._handle_start_game_message)
logger.info("Message bus initialized") logger.info("Message bus initialized")
return True return True
...@@ -471,22 +473,29 @@ class MbetterClientApplication: ...@@ -471,22 +473,29 @@ class MbetterClientApplication:
) )
self.message_bus.publish(ready_message, broadcast=True) self.message_bus.publish(ready_message, broadcast=True)
# Start automated game timer if specified on command line
if self._start_timer_minutes is not None:
logger.info(f"Command line timer enabled, starting {self._start_timer_minutes} minute timer")
self._start_game_timer()
logger.info("MbetterClient application started successfully") logger.info("MbetterClient application started successfully")
# If Qt player is enabled, run its event loop on the main thread # If Qt player is enabled, run its event loop on the main thread
if qt_player_initialized: if qt_player_initialized:
logger.info("Running Qt player event loop on main thread") logger.info("Running Qt player event loop on main thread")
# Setup Qt-specific signal handling since Qt takes over the main thread # Setup Qt-specific signal handling since Qt takes over the main thread
if hasattr(self.qt_player, 'app') and self.qt_player.app: if hasattr(self.qt_player, 'app') and self.qt_player.app:
# Connect Qt's aboutToQuit signal to our shutdown # Connect Qt's aboutToQuit signal to our shutdown
self.qt_player.app.aboutToQuit.connect(self._qt_about_to_quit) self.qt_player.app.aboutToQuit.connect(self._qt_about_to_quit)
# Ensure Qt exits when last window closes # Ensure Qt exits when last window closes
self.qt_player.app.setQuitOnLastWindowClosed(True) self.qt_player.app.setQuitOnLastWindowClosed(True)
return self.qt_player.run() return self.qt_player.run()
else: else:
# Wait for shutdown with a timeout to prevent indefinite hanging # No UI components - keep application running in background mode
logger.info("No UI components enabled - running in background mode")
# Wait for shutdown signal (Ctrl+C, etc.)
while self.running and not self.shutdown_event.is_set(): while self.running and not self.shutdown_event.is_set():
self.shutdown_event.wait(timeout=1.0) self.shutdown_event.wait(timeout=1.0)
...@@ -544,18 +553,20 @@ class MbetterClientApplication: ...@@ -544,18 +553,20 @@ class MbetterClientApplication:
"""Process messages received by the core component""" """Process messages received by the core component"""
try: try:
logger.debug(f"Core processing message: {message}") logger.debug(f"Core processing message: {message}")
if message.type == MessageType.SYSTEM_STATUS: if message.type == MessageType.SYSTEM_STATUS:
self._handle_system_status(message) self._handle_system_status(message)
elif message.type == MessageType.SYSTEM_ERROR: elif message.type == MessageType.SYSTEM_ERROR:
self._handle_system_error(message) self._handle_system_error(message)
elif message.type == MessageType.CONFIG_REQUEST: elif message.type == MessageType.CONFIG_REQUEST:
self._handle_config_request(message) self._handle_config_request(message)
elif message.type == MessageType.START_GAME:
self._handle_start_game_message(message)
elif message.type == MessageType.SYSTEM_SHUTDOWN: elif message.type == MessageType.SYSTEM_SHUTDOWN:
self._handle_shutdown_message(message) self._handle_shutdown_message(message)
else: else:
logger.debug(f"Unhandled message type in core: {message.type}") logger.debug(f"Unhandled message type in core: {message.type}")
except Exception as e: except Exception as e:
logger.error(f"Failed to process core message: {e}") logger.error(f"Failed to process core message: {e}")
...@@ -687,21 +698,13 @@ class MbetterClientApplication: ...@@ -687,21 +698,13 @@ class MbetterClientApplication:
logger.error(f"Failed to handle log entry: {e}") logger.error(f"Failed to handle log entry: {e}")
def _handle_start_game_message(self, message: Message): def _handle_start_game_message(self, message: Message):
"""Handle START_GAME message by starting the timer""" """Handle START_GAME message - only cancel the start-timer as its job is done"""
try: try:
if not self.settings.timer.enabled: # The core should only cancel its start-timer when START_GAME is received
logger.debug("Timer not enabled, ignoring START_GAME message") # The actual START_GAME processing is done by games_thread
return logger.info("START_GAME message received - cancelling command line start-timer as it has completed its job")
# Cancel any existing timer
self._cancel_game_timer() self._cancel_game_timer()
# Start new timer
delay_seconds = self.settings.timer.delay_minutes * 60
logger.info(f"START_GAME message received from {message.sender}, starting timer for {self.settings.timer.delay_minutes} minutes ({delay_seconds} seconds)")
self._game_start_timer()
except Exception as e: except Exception as e:
logger.error(f"Failed to handle START_GAME message: {e}") logger.error(f"Failed to handle START_GAME message: {e}")
...@@ -713,11 +716,11 @@ class MbetterClientApplication: ...@@ -713,11 +716,11 @@ class MbetterClientApplication:
def _start_game_timer(self): def _start_game_timer(self):
"""Start the timer for automated game start""" """Start the timer for automated game start"""
if not self.settings.timer.enabled: if self._start_timer_minutes is None:
return return
delay_seconds = self.settings.timer.delay_minutes * 60 delay_seconds = self._start_timer_minutes * 60
logger.info(f"Starting game timer: {self.settings.timer.delay_minutes} minutes ({delay_seconds} seconds)") logger.info(f"Starting command line game timer: {self._start_timer_minutes} minutes ({delay_seconds} seconds)")
self._game_start_timer = threading.Timer(delay_seconds, self._on_game_timer_expired) self._game_start_timer = threading.Timer(delay_seconds, self._on_game_timer_expired)
self._game_start_timer.daemon = True self._game_start_timer.daemon = True
...@@ -725,19 +728,19 @@ class MbetterClientApplication: ...@@ -725,19 +728,19 @@ class MbetterClientApplication:
def _on_game_timer_expired(self): def _on_game_timer_expired(self):
"""Called when the game start timer expires""" """Called when the game start timer expires"""
logger.info("Game start timer expired, sending START_GAME_DELAYED message") logger.info("Game start timer expired, sending START_GAME message")
try: try:
# Create and send START_GAME_DELAYED message # Create and send START_GAME message
start_game_delayed_message = MessageBuilder.start_game_delayed( start_game_message = MessageBuilder.start_game(
sender="timer", sender="timer",
delay_minutes=self.settings.timer.delay_minutes fixture_id=None # Let the games thread find the best fixture
) )
self.message_bus.publish(start_game_delayed_message, broadcast=True) self.message_bus.publish(start_game_message, broadcast=True)
logger.info("START_GAME_DELAYED message sent successfully") logger.info("START_GAME message sent successfully")
except Exception as e: except Exception as e:
logger.error(f"Failed to send START_GAME_DELAYED message: {e}") logger.error(f"Failed to send START_GAME message: {e}")
def _cancel_game_timer(self): def _cancel_game_timer(self):
"""Cancel the game start timer if it's running""" """Cancel the game start timer if it's running"""
......
...@@ -97,65 +97,55 @@ class GamesThread(ThreadedComponent): ...@@ -97,65 +97,55 @@ class GamesThread(ThreadedComponent):
self.game_active = False self.game_active = False
def _handle_start_game(self, message: Message): def _handle_start_game(self, message: Message):
"""Handle START_GAME message""" """Handle START_GAME message with comprehensive logic"""
try: try:
fixture_id = message.data.get("fixture_id") logger.info(f"Processing START_GAME message from {message.sender}")
if not fixture_id: fixture_id = message.data.get("fixture_id")
# If no fixture_id provided, find the last fixture with pending matches
fixture_id = self._find_last_fixture_with_pending_matches()
if fixture_id: if fixture_id:
logger.info(f"Starting game for fixture: {fixture_id}") # If fixture_id is provided, check if it's in terminal state
self.current_fixture_id = fixture_id if self._is_fixture_all_terminal(fixture_id):
self.game_active = True logger.info(f"Fixture {fixture_id} is in terminal state - discarding START_GAME message")
self._send_response(message, "discarded", f"Fixture {fixture_id} is already completed")
return
# Fixture is not terminal, activate it
logger.info(f"Activating provided fixture: {fixture_id}")
self._activate_fixture(fixture_id, message)
return
# Send game started confirmation # No fixture_id provided - check today's fixtures
response = Message( if self._has_today_fixtures_all_terminal():
type=MessageType.GAME_STATUS, logger.info("All today's fixtures are in terminal states - discarding START_GAME message")
sender=self.name, self._send_response(message, "discarded", "All today's fixtures are already completed")
recipient=message.sender, return
data={
"status": "started",
"fixture_id": fixture_id,
"timestamp": time.time()
},
correlation_id=message.correlation_id
)
self.message_bus.publish(response)
else:
logger.warning("No fixture with pending matches found")
# Send error response # Step 2: Handle matches currently in "ingame" status
error_response = Message( ingame_handled = self._handle_ingame_matches(message)
type=MessageType.GAME_STATUS, if ingame_handled:
sender=self.name, # Message was handled (either discarded or processed) - return
recipient=message.sender, return
data={
"status": "error", # Step 3: Check if there are active fixtures with today's date
"error": "No fixture with pending matches found", active_fixture = self._find_active_today_fixture()
"timestamp": time.time() if active_fixture:
}, logger.info(f"Found active fixture for today: {active_fixture}")
correlation_id=message.correlation_id self._activate_fixture(active_fixture, message)
) return
self.message_bus.publish(error_response)
# Step 4: No active fixtures found - initialize new fixture
logger.info("No active fixtures found - initializing new fixture")
new_fixture_id = self._initialize_new_fixture()
if new_fixture_id:
self._activate_fixture(new_fixture_id, message)
else:
logger.warning("Could not initialize new fixture")
self._send_response(message, "error", "Could not initialize new fixture")
except Exception as e: except Exception as e:
logger.error(f"Failed to handle START_GAME message: {e}") logger.error(f"Failed to handle START_GAME message: {e}")
self._send_response(message, "error", str(e))
# Send error response
error_response = Message(
type=MessageType.GAME_STATUS,
sender=self.name,
recipient=message.sender,
data={
"status": "error",
"error": str(e),
"timestamp": time.time()
},
correlation_id=message.correlation_id
)
self.message_bus.publish(error_response)
def _handle_schedule_games(self, message: Message): def _handle_schedule_games(self, message: Message):
"""Handle SCHEDULE_GAMES message - change status of pending matches to scheduled""" """Handle SCHEDULE_GAMES message - change status of pending matches to scheduled"""
...@@ -251,8 +241,14 @@ class GamesThread(ThreadedComponent): ...@@ -251,8 +241,14 @@ class GamesThread(ThreadedComponent):
try: try:
logger.debug(f"GamesThread processing message: {message}") logger.debug(f"GamesThread processing message: {message}")
# Messages are handled by subscribed handlers, but we can add additional processing here # Handle messages directly since broadcast messages don't trigger subscription handlers
if message.type == MessageType.GAME_UPDATE: if message.type == MessageType.START_GAME:
self._handle_start_game(message)
elif message.type == MessageType.SCHEDULE_GAMES:
self._handle_schedule_games(message)
elif message.type == MessageType.SYSTEM_SHUTDOWN:
self._handle_shutdown_message(message)
elif message.type == MessageType.GAME_UPDATE:
self._handle_game_update(message) self._handle_game_update(message)
except Exception as e: except Exception as e:
...@@ -371,6 +367,534 @@ class GamesThread(ThreadedComponent): ...@@ -371,6 +367,534 @@ class GamesThread(ThreadedComponent):
except Exception as e: except Exception as e:
logger.error(f"Failed to monitor game state: {e}") logger.error(f"Failed to monitor game state: {e}")
def _send_response(self, original_message: Message, status: str, message: str = None):
"""Send response message back to the sender"""
try:
response_data = {
"status": status,
"timestamp": time.time()
}
if message:
response_data["message"] = message
response = Message(
type=MessageType.GAME_STATUS,
sender=self.name,
recipient=original_message.sender,
data=response_data,
correlation_id=original_message.correlation_id
)
self.message_bus.publish(response)
except Exception as e:
logger.error(f"Failed to send response: {e}")
def _is_fixture_all_terminal(self, fixture_id: str) -> bool:
"""Check if all matches in a fixture are in terminal states (done, cancelled, failed, paused)"""
try:
session = self.db_manager.get_session()
try:
# Get all matches for this fixture
matches = session.query(MatchModel).filter(
MatchModel.fixture_id == fixture_id,
MatchModel.active_status == True
).all()
if not matches:
return False # No matches means not terminal
# Check if all matches are in terminal states
terminal_states = ['done', 'cancelled', 'failed', 'paused']
return all(match.status in terminal_states for match in matches)
finally:
session.close()
except Exception as e:
logger.error(f"Failed to check if fixture {fixture_id} is terminal: {e}")
return False
def _has_today_fixtures_all_terminal(self) -> bool:
"""Check if all fixtures with today's matches are in terminal states"""
try:
session = self.db_manager.get_session()
try:
# Get today's date
today = datetime.now().date()
# Find all fixtures that have matches with today's start_time
fixtures_with_today_matches = session.query(MatchModel.fixture_id).filter(
MatchModel.start_time.isnot(None),
MatchModel.start_time >= datetime.combine(today, datetime.min.time()),
MatchModel.start_time < datetime.combine(today, datetime.max.time())
).distinct().all()
if not fixtures_with_today_matches:
return False # No today's fixtures
# Check each fixture
for fixture_row in fixtures_with_today_matches:
fixture_id = fixture_row.fixture_id
if not self._is_fixture_all_terminal(fixture_id):
return False # Found a non-terminal fixture
return True # All fixtures are terminal
finally:
session.close()
except Exception as e:
logger.error(f"Failed to check today's fixtures terminal status: {e}")
return False
def _handle_ingame_matches(self, message: Message) -> bool:
"""Handle matches currently in 'ingame' status. Returns True if message was handled."""
try:
session = self.db_manager.get_session()
try:
# Get today's date
today = datetime.now().date()
# Find fixtures with ingame matches today
ingame_matches = session.query(MatchModel).filter(
MatchModel.start_time.isnot(None),
MatchModel.start_time >= datetime.combine(today, datetime.min.time()),
MatchModel.start_time < datetime.combine(today, datetime.max.time()),
MatchModel.status == 'ingame',
MatchModel.active_status == True
).all()
if not ingame_matches:
return False # No ingame matches, continue processing
# Get unique fixture IDs
fixture_ids = list(set(match.fixture_id for match in ingame_matches))
for fixture_id in fixture_ids:
# Check if timer is running for this fixture
# This is a simplified check - in real implementation you'd check the match_timer component
timer_running = self._is_timer_running_for_fixture(fixture_id)
if not timer_running:
# Timer not running, change status to failed
logger.info(f"Timer not running for fixture {fixture_id}, changing ingame matches to failed")
self._change_fixture_matches_status(fixture_id, 'ingame', 'failed')
# Check if this was the only non-terminal fixture
if self._is_only_non_terminal_fixture(fixture_id):
logger.info("This was the only non-terminal fixture - discarding START_GAME message")
self._send_response(message, "discarded", "Timer not running and no other active fixtures")
return True
else:
# Timer is running, check for other pending/bet/scheduled matches
other_active_matches = session.query(MatchModel).filter(
MatchModel.fixture_id == fixture_id,
MatchModel.status.in_(['pending', 'bet', 'scheduled']),
MatchModel.active_status == True
).all()
if other_active_matches:
# Change first pending/bet/scheduled match to bet status
first_match = other_active_matches[0]
if first_match.status != 'bet':
logger.info(f"Changing match {first_match.match_number} status to bet")
first_match.status = 'bet'
session.commit()
# Timer is running, discard the message
logger.info(f"Timer running for fixture {fixture_id} - discarding START_GAME message")
self._send_response(message, "discarded", "Timer already running for active fixture")
return True
return False # Continue processing
finally:
session.close()
except Exception as e:
logger.error(f"Failed to handle ingame matches: {e}")
return False
def _is_timer_running_for_fixture(self, fixture_id: str) -> bool:
"""Check if timer is running for a specific fixture"""
# This is a simplified implementation
# In a real implementation, you'd check the match_timer component status
return self.current_fixture_id == fixture_id and self.game_active
def _change_fixture_matches_status(self, fixture_id: str, from_status: str, to_status: str):
"""Change status of matches in a fixture from one status to another"""
try:
session = self.db_manager.get_session()
try:
matches = session.query(MatchModel).filter(
MatchModel.fixture_id == fixture_id,
MatchModel.status == from_status,
MatchModel.active_status == True
).all()
for match in matches:
logger.info(f"Changing match {match.match_number} status from {from_status} to {to_status}")
match.status = to_status
session.commit()
finally:
session.close()
except Exception as e:
logger.error(f"Failed to change match statuses: {e}")
def _is_only_non_terminal_fixture(self, fixture_id: str) -> bool:
"""Check if this is the only non-terminal fixture"""
try:
session = self.db_manager.get_session()
try:
# Get today's date
today = datetime.now().date()
# Find all fixtures with today's matches
all_fixtures = session.query(MatchModel.fixture_id).filter(
MatchModel.start_time.isnot(None),
MatchModel.start_time >= datetime.combine(today, datetime.min.time()),
MatchModel.start_time < datetime.combine(today, datetime.max.time())
).distinct().all()
# Check each fixture except the current one
terminal_states = ['done', 'cancelled', 'failed', 'paused']
non_terminal_count = 0
for fixture_row in all_fixtures:
fid = fixture_row.fixture_id
if fid == fixture_id:
continue
# Check if this fixture has non-terminal matches
non_terminal_matches = session.query(MatchModel).filter(
MatchModel.fixture_id == fid,
MatchModel.status.notin_(terminal_states),
MatchModel.active_status == True
).all()
if non_terminal_matches:
non_terminal_count += 1
return non_terminal_count == 0
finally:
session.close()
except Exception as e:
logger.error(f"Failed to check if only non-terminal fixture: {e}")
return False
def _find_active_today_fixture(self) -> Optional[str]:
"""Find an active fixture with today's date"""
try:
session = self.db_manager.get_session()
try:
# Get today's date
today = datetime.now().date()
# Find fixtures with today's matches that are not in terminal states
terminal_states = ['done', 'cancelled', 'failed', 'paused']
active_matches = session.query(MatchModel).filter(
MatchModel.start_time.isnot(None),
MatchModel.start_time >= datetime.combine(today, datetime.min.time()),
MatchModel.start_time < datetime.combine(today, datetime.max.time()),
MatchModel.status.notin_(terminal_states),
MatchModel.active_status == True
).order_by(MatchModel.start_time.asc()).all()
if active_matches:
return active_matches[0].fixture_id
return None
finally:
session.close()
except Exception as e:
logger.error(f"Failed to find active today fixture: {e}")
return None
def _initialize_new_fixture(self) -> Optional[str]:
"""Initialize a new fixture by finding the first one with no start_time set"""
try:
session = self.db_manager.get_session()
try:
# Find the first fixture with no start_time set
fixtures_no_start_time = session.query(MatchModel.fixture_id).filter(
MatchModel.start_time.is_(None),
MatchModel.active_status == True
).distinct().order_by(MatchModel.created_at.asc()).all()
if not fixtures_no_start_time:
return None
fixture_id = fixtures_no_start_time[0].fixture_id
logger.info(f"Initializing new fixture: {fixture_id}")
# Set start_time to now for all matches in this fixture
now = datetime.utcnow()
matches = session.query(MatchModel).filter(
MatchModel.fixture_id == fixture_id,
MatchModel.active_status == True
).all()
for match in matches:
match.start_time = now
match.status = 'scheduled'
logger.debug(f"Set start_time for match {match.match_number}")
session.commit()
return fixture_id
finally:
session.close()
except Exception as e:
logger.error(f"Failed to initialize new fixture: {e}")
return None
def _activate_fixture(self, fixture_id: str, message: Message):
"""Activate a fixture and start the game"""
try:
logger.info(f"🎯 ACTIVATING FIXTURE: {fixture_id}")
# Check if fixture is already active to prevent double activation
if self.current_fixture_id == fixture_id and self.game_active:
logger.warning(f"Fixture {fixture_id} is already active - ignoring duplicate activation")
self._send_response(message, "already_active", f"Fixture {fixture_id} already active")
return
# Set current fixture
self.current_fixture_id = fixture_id
self.game_active = True
# Step 1 & 2: Change match statuses in a single transaction
logger.info(f"🔄 Starting match status changes for fixture {fixture_id}")
self._schedule_and_apply_betting_logic(fixture_id)
# Send game started confirmation
logger.info(f"✅ Fixture {fixture_id} activated successfully")
self._send_response(message, "started", f"Fixture {fixture_id} activated")
# Start match timer
logger.info(f"⏰ Starting match timer for fixture {fixture_id}")
self._start_match_timer(fixture_id)
# Refresh dashboard statuses
self._refresh_dashboard_statuses()
except Exception as e:
logger.error(f"❌ Failed to activate fixture {fixture_id}: {e}")
import traceback
logger.error(f"Stack trace: {traceback.format_exc()}")
self._send_response(message, "error", f"Failed to activate fixture: {str(e)}")
def _schedule_and_apply_betting_logic(self, fixture_id: str):
"""Change match statuses in a single transaction: first to 'scheduled', then apply betting logic"""
try:
logger.info(f"🔄 Starting match status update for fixture {fixture_id}")
# Get betting mode configuration from database (default to 'all_bets_on_start')
betting_mode = self._get_betting_mode_config()
logger.info(f"📋 Using betting mode: {betting_mode}")
session = self.db_manager.get_session()
try:
# First, let's see what matches exist for this fixture
all_matches = session.query(MatchModel).filter(
MatchModel.fixture_id == fixture_id,
MatchModel.active_status == True
).all()
if not all_matches:
logger.warning(f"⚠️ No matches found for fixture {fixture_id}")
return
logger.info(f"📊 Found {len(all_matches)} total matches in fixture {fixture_id}")
for match in all_matches:
logger.info(f" Match {match.match_number}: {match.fighter1_township} vs {match.fighter2_township} - Status: {match.status}")
# Step 1: Change ALL matches in the fixture to 'scheduled' status first
terminal_states = ['done', 'cancelled', 'failed', 'paused']
matches = session.query(MatchModel).filter(
MatchModel.fixture_id == fixture_id,
MatchModel.status.notin_(terminal_states),
MatchModel.active_status == True
).all()
logger.info(f"📋 Found {len(matches)} non-terminal matches to process")
scheduled_count = 0
for match in matches:
if match.status != 'scheduled':
logger.info(f"🔄 Changing match {match.match_number} status from '{match.status}' to 'scheduled'")
match.status = 'scheduled'
scheduled_count += 1
else:
logger.info(f"✅ Match {match.match_number} already scheduled")
# Flush to make sure scheduled status is available for next step
if scheduled_count > 0:
session.flush()
logger.info(f"✅ Scheduled {scheduled_count} matches in fixture {fixture_id}")
else:
logger.info("📋 No matches needed scheduling")
# Step 2: Apply betting logic based on configuration
if betting_mode == 'all_bets_on_start':
logger.info("🎰 Applying 'all_bets_on_start' logic")
# Change ALL scheduled matches to 'bet' status
scheduled_matches = session.query(MatchModel).filter(
MatchModel.fixture_id == fixture_id,
MatchModel.status == 'scheduled',
MatchModel.active_status == True
).all()
logger.info(f"📋 Found {len(scheduled_matches)} scheduled matches to change to 'bet'")
bet_count = 0
for match in scheduled_matches:
logger.info(f"🎰 Changing match {match.match_number} status to 'bet' (all bets on start)")
match.status = 'bet'
bet_count += 1
if bet_count > 0:
logger.info(f"✅ Changed {bet_count} matches to 'bet' status (all bets on start mode)")
else:
logger.warning("⚠️ No scheduled matches found to change to 'bet' status")
else: # 'one_bet_at_a_time'
logger.info("🎯 Applying 'one_bet_at_a_time' logic")
# Change only the FIRST scheduled match to 'bet' status
first_match = session.query(MatchModel).filter(
MatchModel.fixture_id == fixture_id,
MatchModel.status == 'scheduled',
MatchModel.active_status == True
).order_by(MatchModel.match_number.asc()).first()
if first_match:
logger.info(f"🎯 Changing first match {first_match.match_number} status to 'bet' (one bet at a time)")
first_match.status = 'bet'
else:
logger.warning("⚠️ No scheduled match found to change to 'bet' status")
# Commit all changes in a single transaction
logger.info("💾 Committing database changes...")
session.commit()
logger.info(f"✅ Successfully updated match statuses for fixture {fixture_id} with betting mode: {betting_mode}")
# Send notification to web dashboard about fixture status update
try:
from .message_bus import Message, MessageType
status_update_message = Message(
type=MessageType.CUSTOM,
sender=self.name,
recipient="web_dashboard",
data={
"fixture_status_update": {
"fixture_id": fixture_id,
"betting_mode": betting_mode,
"timestamp": time.time()
}
}
)
self.message_bus.publish(status_update_message)
logger.info(f"📢 Broadcast fixture status update notification for {fixture_id}")
except Exception as msg_e:
logger.warning(f"Failed to send fixture status update notification: {msg_e}")
# Verify the changes were applied
final_matches = session.query(MatchModel).filter(
MatchModel.fixture_id == fixture_id,
MatchModel.active_status == True
).all()
logger.info(f"🔍 Final match statuses for fixture {fixture_id}:")
for match in final_matches:
logger.info(f" Match {match.match_number}: {match.status}")
finally:
session.close()
except Exception as e:
logger.error(f"❌ Failed to schedule and apply betting logic: {e}")
import traceback
logger.error(f"Stack trace: {traceback.format_exc()}")
# Try to rollback in case of error
try:
if 'session' in locals():
session.rollback()
except Exception as rollback_e:
logger.error(f"Failed to rollback: {rollback_e}")
def _get_betting_mode_config(self) -> str:
"""Get betting mode configuration from database (default: 'all_bets_on_start')"""
try:
session = self.db_manager.get_session()
try:
from ..database.models import BettingModeModel
# Get the first betting mode configuration (assuming it's system-wide)
# If user-specific, this would need to be modified to get user context
betting_mode_entry = session.query(BettingModeModel).first()
if betting_mode_entry:
return betting_mode_entry.mode
else:
# Default to 'all_bets_on_start' if no configuration found
return 'all_bets_on_start'
finally:
session.close()
except Exception as e:
logger.error(f"Failed to get betting mode config: {e}")
# Default fallback
return 'all_bets_on_start'
def _refresh_dashboard_statuses(self):
"""Refresh dashboard statuses by sending update messages"""
try:
# Send refresh message to web dashboard
refresh_message = Message(
type=MessageType.GAME_STATUS,
sender=self.name,
data={
"action": "refresh",
"timestamp": time.time()
}
)
self.message_bus.publish(refresh_message, broadcast=True)
except Exception as e:
logger.error(f"Failed to refresh dashboard statuses: {e}")
def _start_match_timer(self, fixture_id: str):
"""Start the match timer for the fixture"""
try:
# Send message to match timer component
timer_message = Message(
type=MessageType.START_GAME,
sender=self.name,
recipient="match_timer",
data={
"fixture_id": fixture_id,
"action": "start_timer",
"timestamp": time.time()
}
)
self.message_bus.publish(timer_message)
logger.info(f"Started match timer for fixture {fixture_id}")
except Exception as e:
logger.error(f"Failed to start match timer: {e}")
def _cleanup(self): def _cleanup(self):
"""Perform cleanup operations""" """Perform cleanup operations"""
try: try:
......
...@@ -61,6 +61,11 @@ class MatchTimerComponent(ThreadedComponent): ...@@ -61,6 +61,11 @@ class MatchTimerComponent(ThreadedComponent):
while self.running: while self.running:
try: try:
# Process any pending messages first
message = self.message_bus.get_message(self.name, timeout=0.1)
if message:
self._process_message(message)
current_time = time.time() current_time = time.time()
# Check if timer needs to be updated # Check if timer needs to be updated
...@@ -72,8 +77,10 @@ class MatchTimerComponent(ThreadedComponent): ...@@ -72,8 +77,10 @@ class MatchTimerComponent(ThreadedComponent):
# Timer reached zero, start next match # Timer reached zero, start next match
self._on_timer_expired() self._on_timer_expired()
else: else:
# Update last activity # Send periodic timer updates (every 1 second)
self._last_update = current_time if current_time - self._last_update >= 1.0:
self._send_timer_update()
self._last_update = current_time
# Check if we should stop the timer (no more matches) # Check if we should stop the timer (no more matches)
if self.timer_running and self._should_stop_timer(): if self.timer_running and self._should_stop_timer():
...@@ -92,6 +99,22 @@ class MatchTimerComponent(ThreadedComponent): ...@@ -92,6 +99,22 @@ class MatchTimerComponent(ThreadedComponent):
logger.info("MatchTimer component stopped") logger.info("MatchTimer component stopped")
def _process_message(self, message):
"""Process incoming messages directly"""
try:
logger.debug(f"MatchTimer processing message: {message}")
# Handle messages directly since some messages don't trigger subscription handlers
if message.type == MessageType.START_GAME:
self._handle_start_game(message)
elif message.type == MessageType.SCHEDULE_GAMES:
self._handle_schedule_games(message)
elif message.type == MessageType.CUSTOM:
self._handle_custom_message(message)
except Exception as e:
logger.error(f"Failed to process message: {e}")
def shutdown(self): def shutdown(self):
"""Shutdown the match timer""" """Shutdown the match timer"""
with self._timer_lock: with self._timer_lock:
......
...@@ -133,9 +133,10 @@ class Message: ...@@ -133,9 +133,10 @@ class Message:
class MessageBus: class MessageBus:
"""Central message bus for inter-thread communication""" """Central message bus for inter-thread communication"""
def __init__(self, max_queue_size: int = 1000): def __init__(self, max_queue_size: int = 1000, dev_message: bool = False):
self.max_queue_size = max_queue_size self.max_queue_size = max_queue_size
self.dev_message = dev_message
self._queues: Dict[str, Queue] = {} self._queues: Dict[str, Queue] = {}
self._handlers: Dict[str, Dict[MessageType, List[Callable]]] = {} self._handlers: Dict[str, Dict[MessageType, List[Callable]]] = {}
self._global_handlers: Dict[MessageType, List[Callable]] = {} self._global_handlers: Dict[MessageType, List[Callable]] = {}
...@@ -143,8 +144,11 @@ class MessageBus: ...@@ -143,8 +144,11 @@ class MessageBus:
self._lock = threading.RLock() self._lock = threading.RLock()
self._message_history: List[Message] = [] self._message_history: List[Message] = []
self._max_history = 1000 self._max_history = 1000
logger.info("MessageBus initialized") if dev_message:
logger.info("MessageBus initialized with dev_message mode enabled")
else:
logger.info("MessageBus initialized")
def register_component(self, component_name: str) -> Queue: def register_component(self, component_name: str) -> Queue:
"""Register a component and get its message queue""" """Register a component and get its message queue"""
...@@ -200,8 +204,9 @@ class MessageBus: ...@@ -200,8 +204,9 @@ class MessageBus:
# Add to message history # Add to message history
self._add_to_history(message) self._add_to_history(message)
# Log the message # Log the message (only in dev_message mode)
logger.debug(f"Publishing message: {message}") if self.dev_message:
logger.info(f"📨 MESSAGE_BUS: {message}")
if broadcast or message.recipient is None: if broadcast or message.recipient is None:
# Broadcast to all components # Broadcast to all components
......
...@@ -982,6 +982,55 @@ class Migration_014_AddExtractionAndGameConfigTables(DatabaseMigration): ...@@ -982,6 +982,55 @@ class Migration_014_AddExtractionAndGameConfigTables(DatabaseMigration):
return False return False
class Migration_015_AddBettingModeTable(DatabaseMigration):
"""Add betting_modes table for user betting preferences"""
def __init__(self):
super().__init__("015", "Add betting_modes table for user betting preferences")
def up(self, db_manager) -> bool:
"""Create betting_modes table"""
try:
with db_manager.engine.connect() as conn:
# Create betting_modes table
conn.execute(text("""
CREATE TABLE IF NOT EXISTS betting_modes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL UNIQUE,
mode VARCHAR(50) NOT NULL DEFAULT 'all_bets_on_start',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
)
"""))
# Create indexes for betting_modes table
conn.execute(text("""
CREATE INDEX IF NOT EXISTS ix_betting_modes_user_id ON betting_modes(user_id)
"""))
conn.commit()
logger.info("Betting modes table created successfully")
return True
except Exception as e:
logger.error(f"Failed to create betting modes table: {e}")
return False
def down(self, db_manager) -> bool:
"""Drop betting_modes table"""
try:
with db_manager.engine.connect() as conn:
conn.execute(text("DROP TABLE IF EXISTS betting_modes"))
conn.commit()
logger.info("Betting modes table dropped")
return True
except Exception as e:
logger.error(f"Failed to drop betting modes table: {e}")
return False
# Registry of all migrations in order # Registry of all migrations in order
MIGRATIONS: List[DatabaseMigration] = [ MIGRATIONS: List[DatabaseMigration] = [
Migration_001_InitialSchema(), Migration_001_InitialSchema(),
...@@ -998,6 +1047,7 @@ MIGRATIONS: List[DatabaseMigration] = [ ...@@ -998,6 +1047,7 @@ MIGRATIONS: List[DatabaseMigration] = [
Migration_012_RemoveFixtureIdUniqueConstraint(), Migration_012_RemoveFixtureIdUniqueConstraint(),
Migration_013_AddStatusFieldToMatches(), Migration_013_AddStatusFieldToMatches(),
Migration_014_AddExtractionAndGameConfigTables(), Migration_014_AddExtractionAndGameConfigTables(),
Migration_015_AddBettingModeTable(),
] ]
......
...@@ -20,6 +20,7 @@ class MatchStatus(str, Enum): ...@@ -20,6 +20,7 @@ class MatchStatus(str, Enum):
SCHEDULED = "scheduled" SCHEDULED = "scheduled"
BET = "bet" BET = "bet"
INGAME = "ingame" INGAME = "ingame"
DONE = "done"
CANCELLED = "cancelled" CANCELLED = "cancelled"
FAILED = "failed" FAILED = "failed"
PAUSED = "paused" PAUSED = "paused"
...@@ -95,6 +96,7 @@ class UserModel(BaseModel): ...@@ -95,6 +96,7 @@ class UserModel(BaseModel):
# Relationships # Relationships
api_tokens = relationship('ApiTokenModel', back_populates='user', cascade='all, delete-orphan') api_tokens = relationship('ApiTokenModel', back_populates='user', cascade='all, delete-orphan')
log_entries = relationship('LogEntryModel', back_populates='user') log_entries = relationship('LogEntryModel', back_populates='user')
betting_mode = relationship('BettingModeModel', back_populates='user', cascade='all, delete-orphan', uselist=False)
def set_password(self, password: str): def set_password(self, password: str):
"""Set password hash using SHA-256 with salt (consistent with AuthManager)""" """Set password hash using SHA-256 with salt (consistent with AuthManager)"""
...@@ -482,7 +484,7 @@ class MatchModel(BaseModel): ...@@ -482,7 +484,7 @@ class MatchModel(BaseModel):
result = Column(String(255), comment='Match result/outcome') result = Column(String(255), comment='Match result/outcome')
done = Column(Boolean, default=False, nullable=False, comment='Match completion flag (0=pending, 1=done)') done = Column(Boolean, default=False, nullable=False, comment='Match completion flag (0=pending, 1=done)')
running = Column(Boolean, default=False, nullable=False, comment='Match running flag (0=not running, 1=running)') running = Column(Boolean, default=False, nullable=False, comment='Match running flag (0=not running, 1=running)')
status = Column(Enum('pending', 'scheduled', 'bet', 'ingame', 'cancelled', 'failed', 'paused'), default='pending', nullable=False, comment='Match status enum') status = Column(Enum('pending', 'scheduled', 'bet', 'ingame', 'done', 'cancelled', 'failed', 'paused'), default='pending', nullable=False, comment='Match status enum')
fixture_active_time = Column(Integer, nullable=True, comment='Unix timestamp when fixture became active on server') fixture_active_time = Column(Integer, nullable=True, comment='Unix timestamp when fixture became active on server')
# File metadata # File metadata
...@@ -657,4 +659,22 @@ class GameConfigModel(BaseModel): ...@@ -657,4 +659,22 @@ class GameConfigModel(BaseModel):
self.value_type = 'string' self.value_type = 'string'
def __repr__(self): def __repr__(self):
return f'<GameConfig {self.config_key}={self.config_value}>' return f'<GameConfig {self.config_key}={self.config_value}>'
\ No newline at end of file
class BettingModeModel(BaseModel):
"""Betting mode configuration"""
__tablename__ = 'betting_modes'
__table_args__ = (
Index('ix_betting_modes_user_id', 'user_id'),
UniqueConstraint('user_id', name='uq_betting_modes_user_id'),
)
user_id = Column(Integer, ForeignKey('users.id'), nullable=False, unique=True, comment='User who owns this betting mode setting')
mode = Column(String(50), nullable=False, default='all_bets_on_start', comment='Betting mode: all_bets_on_start or one_bet_at_a_time')
# Relationships
user = relationship('UserModel', back_populates='betting_mode')
def __repr__(self):
return f'<BettingMode user_id={self.user_id} mode={self.mode}>'
\ No newline at end of file
...@@ -538,14 +538,51 @@ class DashboardAPI: ...@@ -538,14 +538,51 @@ class DashboardAPI:
def _get_components_status(self) -> Dict[str, Any]: def _get_components_status(self) -> Dict[str, Any]:
"""Get status of all components""" """Get status of all components"""
# This would normally maintain a cache of component status try:
# or query components through message bus from flask import g
return {
"qt_player": "unknown", # Get main application from Flask context
"web_dashboard": "running", main_app = g.get('main_app')
"api_client": "unknown", if main_app and hasattr(main_app, 'thread_manager'):
"message_bus": "running" # Get real component status from thread manager
} components_status = {}
# Get all registered components
component_names = main_app.thread_manager.get_component_names()
for component_name in component_names:
if main_app.thread_manager.is_component_running(component_name):
if main_app.thread_manager.is_component_healthy(component_name):
components_status[component_name] = "running"
else:
components_status[component_name] = "unhealthy"
else:
components_status[component_name] = "stopped"
# Always show web dashboard as running since we're responding
components_status["web_dashboard"] = "running"
# Always show message bus as running since we can query it
components_status["message_bus"] = "running"
return components_status
else:
# Fallback to hardcoded values if main app not available
return {
"qt_player": "unknown",
"web_dashboard": "running",
"api_client": "unknown",
"message_bus": "running"
}
except Exception as e:
logger.error(f"Failed to get real component status: {e}")
# Fallback to hardcoded values on error
return {
"qt_player": "unknown",
"web_dashboard": "running",
"api_client": "unknown",
"message_bus": "running"
}
def send_test_message(self, recipient: str, message_type: str, def send_test_message(self, recipient: str, message_type: str,
data: Dict[str, Any]) -> Dict[str, Any]: data: Dict[str, Any]) -> Dict[str, Any]:
......
...@@ -298,8 +298,16 @@ class WebDashboard(ThreadedComponent): ...@@ -298,8 +298,16 @@ class WebDashboard(ThreadedComponent):
def _process_message(self, message: Message): def _process_message(self, message: Message):
"""Process received message""" """Process received message"""
try: try:
# Messages are handled by subscribed handlers logger.debug(f"WebDashboard processing message: {message}")
pass
# Handle messages directly since some messages don't trigger subscription handlers
if message.type == MessageType.CONFIG_UPDATE:
self._handle_config_update(message)
elif message.type == MessageType.SYSTEM_STATUS:
self._handle_system_status(message)
elif message.type == MessageType.CUSTOM:
self._handle_custom_message(message)
except Exception as e: except Exception as e:
logger.error(f"Failed to process message: {e}") logger.error(f"Failed to process message: {e}")
...@@ -336,6 +344,7 @@ class WebDashboard(ThreadedComponent): ...@@ -336,6 +344,7 @@ class WebDashboard(ThreadedComponent):
"""Handle custom messages (like timer state responses)""" """Handle custom messages (like timer state responses)"""
try: try:
response = message.data.get("response") response = message.data.get("response")
timer_update = message.data.get("timer_update")
if response == "timer_state": if response == "timer_state":
# Update stored timer state # Update stored timer state
...@@ -355,6 +364,26 @@ class WebDashboard(ThreadedComponent): ...@@ -355,6 +364,26 @@ class WebDashboard(ThreadedComponent):
} }
logger.debug("Timer stopped") logger.debug("Timer stopped")
elif timer_update:
# Handle periodic timer updates from match_timer component
self.current_timer_state.update(timer_update)
logger.debug(f"Timer update received: {timer_update}")
# Broadcast timer update to connected clients via global message bus
try:
timer_update_message = Message(
type=MessageType.CUSTOM,
sender=self.name,
data={
"timer_update": timer_update,
"timestamp": time.time()
}
)
self.message_bus.publish(timer_update_message, broadcast=True)
logger.debug("Timer update broadcasted to clients")
except Exception as broadcast_e:
logger.error(f"Failed to broadcast timer update: {broadcast_e}")
except Exception as e: except Exception as e:
logger.error(f"Failed to handle custom message: {e}") logger.error(f"Failed to handle custom message: {e}")
......
...@@ -1299,12 +1299,12 @@ def get_cashier_pending_matches(): ...@@ -1299,12 +1299,12 @@ def get_cashier_pending_matches():
for fixture_row in all_fixtures: for fixture_row in all_fixtures:
fixture_id = fixture_row.fixture_id fixture_id = fixture_row.fixture_id
# Check if all matches in this fixture are pending # Check if all matches in this fixture are pending, scheduled, or bet
fixture_matches = session.query(MatchModel).filter_by(fixture_id=fixture_id).all() fixture_matches = session.query(MatchModel).filter_by(fixture_id=fixture_id).all()
if fixture_matches and all(match.status == 'pending' for match in fixture_matches): if fixture_matches and all(match.status in ['pending', 'scheduled', 'bet'] for match in fixture_matches):
selected_fixture_id = fixture_id selected_fixture_id = fixture_id
logger.info(f"Selected fixture {selected_fixture_id} - all matches are pending") logger.info(f"Selected fixture {selected_fixture_id} - all matches are in playable status (pending/scheduled/bet)")
break break
# If no fixture with all pending matches found, use the first fixture by creation date # If no fixture with all pending matches found, use the first fixture by creation date
...@@ -1663,12 +1663,12 @@ def start_next_match(): ...@@ -1663,12 +1663,12 @@ def start_next_match():
for fixture_row in all_fixtures: for fixture_row in all_fixtures:
fixture_id = fixture_row.fixture_id fixture_id = fixture_row.fixture_id
# Check if all matches in this fixture are pending # Check if all matches in this fixture are pending, scheduled, or bet
fixture_matches = session.query(MatchModel).filter_by(fixture_id=fixture_id).all() fixture_matches = session.query(MatchModel).filter_by(fixture_id=fixture_id).all()
if fixture_matches and all(match.status == 'pending' for match in fixture_matches): if fixture_matches and all(match.status in ['pending', 'scheduled', 'bet'] for match in fixture_matches):
selected_fixture_id = fixture_id selected_fixture_id = fixture_id
logger.info(f"Selected fixture {selected_fixture_id} - all matches are pending") logger.info(f"Selected fixture {selected_fixture_id} - all matches are in playable status (pending/scheduled/bet)")
break break
# If no fixture with all pending matches found, use the first fixture by creation date # If no fixture with all pending matches found, use the first fixture by creation date
...@@ -1815,21 +1815,51 @@ def notifications(): ...@@ -1815,21 +1815,51 @@ def notifications():
} }
notification_queue.append(notification_data) notification_queue.append(notification_data)
notification_received.set() notification_received.set()
elif message.type == MessageType.CUSTOM and "timer_update" in message.data:
# Handle timer updates from match_timer component
notification_data = {
"type": "TIMER_UPDATE",
"data": message.data["timer_update"],
"timestamp": message.timestamp,
"sender": message.sender
}
notification_queue.append(notification_data)
notification_received.set()
elif message.type == MessageType.CUSTOM and "fixture_status_update" in message.data:
# Handle fixture status updates from games_thread
notification_data = {
"type": "FIXTURE_STATUS_UPDATE",
"data": message.data["fixture_status_update"],
"timestamp": message.timestamp,
"sender": message.sender
}
notification_queue.append(notification_data)
notification_received.set()
# Subscribe to relevant message types # Subscribe to relevant message types
if api_bp.message_bus: if api_bp.message_bus:
api_bp.message_bus.subscribe_global(MessageType.START_GAME, message_handler) api_bp.message_bus.subscribe_global(MessageType.START_GAME, message_handler)
api_bp.message_bus.subscribe_global(MessageType.MATCH_START, message_handler) api_bp.message_bus.subscribe_global(MessageType.MATCH_START, message_handler)
api_bp.message_bus.subscribe_global(MessageType.GAME_STATUS, message_handler) api_bp.message_bus.subscribe_global(MessageType.GAME_STATUS, message_handler)
api_bp.message_bus.subscribe_global(MessageType.CUSTOM, message_handler)
# Wait for notification or timeout # Wait for notification or timeout
notification_received.wait(timeout=timeout) notification_received.wait(timeout=timeout)
# Unsubscribe from messages # Unsubscribe from messages safely
if api_bp.message_bus: if api_bp.message_bus:
api_bp.message_bus._global_handlers[MessageType.START_GAME].remove(message_handler) try:
api_bp.message_bus._global_handlers[MessageType.MATCH_START].remove(message_handler) # Use proper unsubscribe methods instead of direct removal
api_bp.message_bus._global_handlers[MessageType.GAME_STATUS].remove(message_handler) for msg_type in [MessageType.START_GAME, MessageType.MATCH_START, MessageType.GAME_STATUS, MessageType.CUSTOM]:
try:
if hasattr(api_bp.message_bus, '_global_handlers') and msg_type in api_bp.message_bus._global_handlers:
handlers = api_bp.message_bus._global_handlers[msg_type]
if message_handler in handlers:
handlers.remove(message_handler)
except (AttributeError, KeyError, ValueError) as e:
logger.debug(f"Handler cleanup warning for {msg_type}: {e}")
except Exception as e:
logger.warning(f"Error during notification cleanup: {e}")
if notification_queue: if notification_queue:
# Return the first notification received # Return the first notification received
......
...@@ -14,7 +14,7 @@ window.Dashboard = (function() { ...@@ -14,7 +14,7 @@ window.Dashboard = (function() {
// Initialize dashboard // Initialize dashboard
function init(userConfig) { function init(userConfig) {
config = Object.assign({ config = Object.assign({
statusUpdateInterval: 30000, statusUpdateInterval: 5000,
apiEndpoint: '/api', apiEndpoint: '/api',
user: null user: null
}, userConfig); }, userConfig);
...@@ -450,84 +450,178 @@ window.Dashboard = (function() { ...@@ -450,84 +450,178 @@ window.Dashboard = (function() {
}); });
} }
// Match Timer functionality // Server-side Match Timer functionality
let matchTimerInterval = null; let matchTimerInterval = null;
let matchTimerSeconds = 0; let serverTimerState = {
let matchTimerRunning = false; running: false,
remaining_seconds: 0,
total_seconds: 0,
fixture_id: null,
match_id: null,
start_time: null
};
let lastServerSync = 0;
let cachedMatchInterval = null; // Cache the match interval configuration
const SYNC_INTERVAL = 30000; // Sync with server every 30 seconds
function initMatchTimer() { function initMatchTimer() {
// Get match interval configuration console.log('Initializing server-only match timer (no local countdown)...');
apiRequest('GET', '/match-timer/config')
// Load match interval config once at initialization
loadMatchIntervalConfig().then(function(intervalSeconds) {
console.log('Match timer config loaded at initialization:', intervalSeconds, 'seconds');
// Initial sync with server
syncWithServerTimer();
// REMOVED: No periodic sync - rely on notifications
// REMOVED: No local countdown - rely on server updates only
}).catch(function(error) {
console.error('Failed to load match timer config at initialization:', error);
// Use default and continue
cachedMatchInterval = 20 * 60; // Default 20 minutes
// Initial sync with server
syncWithServerTimer();
});
}
function syncWithServerTimer() {
const now = Date.now();
apiRequest('GET', '/match-timer/state')
.then(function(data) {
if (data.success) {
// Update server state
serverTimerState = {
running: data.running || false,
remaining_seconds: data.remaining_seconds || 0,
total_seconds: data.total_seconds || 0,
fixture_id: data.fixture_id || null,
match_id: data.match_id || null,
start_time: data.start_time || null
};
lastServerSync = now;
console.log('Synced with server timer:', serverTimerState);
// Update display immediately
updateMatchTimerDisplay();
// No local countdown needed - server handles everything
console.log('Server timer state updated, display refreshed');
} else {
console.error('Failed to sync with server timer:', data);
}
})
.catch(function(error) {
console.error('Failed to sync with server timer:', error);
// Continue with local countdown if server is unavailable
});
}
function loadMatchIntervalConfig() {
// Only load config if we don't have it cached
if (cachedMatchInterval !== null) {
return Promise.resolve(cachedMatchInterval);
}
return apiRequest('GET', '/match-timer/config')
.then(function(data) { .then(function(data) {
if (data.success && data.match_interval) { if (data.success && data.match_interval) {
matchTimerSeconds = data.match_interval * 60; // Convert minutes to seconds cachedMatchInterval = data.match_interval * 60; // Convert minutes to seconds
startMatchTimer(); console.log('Loaded match interval config:', cachedMatchInterval, 'seconds');
return cachedMatchInterval;
} else { } else {
console.error('Failed to get match timer config:', data); console.error('Failed to get match timer config:', data);
// Fallback to 20 minutes // Fallback to 20 minutes
matchTimerSeconds = 20 * 60; cachedMatchInterval = 20 * 60;
startMatchTimer(); return cachedMatchInterval;
} }
}) })
.catch(function(error) { .catch(function(error) {
console.error('Failed to initialize match timer:', error); console.error('Failed to load match timer config:', error);
// Fallback to 20 minutes // Fallback to 20 minutes
matchTimerSeconds = 20 * 60; cachedMatchInterval = 20 * 60;
startMatchTimer(); return cachedMatchInterval;
}); });
} }
function startMatchTimer() { function startLocalCountdown() {
if (matchTimerInterval) { // DISABLE local countdown - rely only on server updates
console.log('Local countdown disabled - using server-only updates');
if (matchTimerInterval && matchTimerInterval !== 'sync') {
clearInterval(matchTimerInterval); clearInterval(matchTimerInterval);
} }
matchTimerRunning = true; // Only update display, no local counting
updateMatchTimerDisplay(); updateMatchTimerDisplay();
matchTimerInterval = setInterval(function() {
if (matchTimerSeconds > 0) {
matchTimerSeconds--;
updateMatchTimerDisplay();
} else {
// Timer reached 0, start next match
startNextMatch();
}
}, 1000);
} }
function stopMatchTimer() { function stopLocalCountdown() {
matchTimerRunning = false; if (matchTimerInterval && matchTimerInterval !== 'sync') {
if (matchTimerInterval) {
clearInterval(matchTimerInterval); clearInterval(matchTimerInterval);
matchTimerInterval = null; matchTimerInterval = null;
} }
} }
function resetMatchTimer(seconds) { function updateMatchTimerDisplay() {
matchTimerSeconds = seconds || (20 * 60); // Default to 20 minutes let displaySeconds = serverTimerState.remaining_seconds;
if (!matchTimerRunning) {
startMatchTimer(); // Update status text based on timer state
const timerStatus = document.getElementById('timer-status');
if (timerStatus) {
if (serverTimerState.running) {
timerStatus.textContent = 'Timer running';
timerStatus.className = 'text-success';
} else {
timerStatus.textContent = 'Waiting for games to start...';
timerStatus.className = 'text-muted';
}
}
// If server timer is not running, show configured interval
if (!serverTimerState.running) {
// Use cached config if available, otherwise load it
if (cachedMatchInterval !== null) {
updateTimerElements(cachedMatchInterval);
} else {
// Only load config if not cached (should happen at initialization)
// Don't load it here to avoid repeated API calls
updateTimerElements(20 * 60); // Use default 20 minutes
}
} else {
updateTimerElements(displaySeconds);
} }
updateMatchTimerDisplay();
} }
function updateMatchTimerDisplay() { function updateTimerElements(seconds) {
const minutes = Math.floor(matchTimerSeconds / 60); const minutes = Math.floor(seconds / 60);
const seconds = matchTimerSeconds % 60; const remainingSeconds = seconds % 60;
const timeString = minutes.toString().padStart(2, '0') + ':' + seconds.toString().padStart(2, '0'); const timeString = minutes.toString().padStart(2, '0') + ':' + remainingSeconds.toString().padStart(2, '0');
// Update status bar timer // Update status bar timer
const statusTimer = document.getElementById('match-timer'); const statusTimer = document.getElementById('match-timer');
if (statusTimer) { if (statusTimer) {
statusTimer.textContent = timeString; statusTimer.textContent = timeString;
// Change color when timer is low
if (matchTimerSeconds <= 60) { // Last minute // Change color based on time remaining
if (seconds <= 60) { // Last minute
statusTimer.className = 'badge bg-danger text-white'; statusTimer.className = 'badge bg-danger text-white';
} else if (matchTimerSeconds <= 300) { // Last 5 minutes } else if (seconds <= 300) { // Last 5 minutes
statusTimer.className = 'badge bg-warning text-dark'; statusTimer.className = 'badge bg-warning text-dark';
} else { } else {
statusTimer.className = 'badge bg-warning text-dark'; statusTimer.className = 'badge bg-secondary text-white';
}
// Add pulse animation for last minute
if (seconds <= 60) {
statusTimer.style.animation = 'timerPulse 0.5s infinite';
} else {
statusTimer.style.animation = '';
} }
} }
...@@ -535,55 +629,77 @@ window.Dashboard = (function() { ...@@ -535,55 +629,77 @@ window.Dashboard = (function() {
const navbarTimer = document.getElementById('match-timer-display'); const navbarTimer = document.getElementById('match-timer-display');
if (navbarTimer) { if (navbarTimer) {
navbarTimer.textContent = timeString; navbarTimer.textContent = timeString;
navbarTimer.className = matchTimerSeconds <= 60 ? 'text-danger fw-bold' : 'text-warning fw-bold'; navbarTimer.className = seconds <= 60 ? 'text-danger fw-bold' : 'text-warning fw-bold';
}
// Update admin dashboard timer if present
const adminTimer = document.getElementById('admin-match-timer');
if (adminTimer) {
adminTimer.textContent = timeString;
adminTimer.className = seconds <= 60 ? 'text-danger fw-bold' : 'text-warning fw-bold';
} }
} }
function startNextMatch() { function onServerTimerExpired() {
console.log('Match timer reached 0, starting next match...'); console.log('Server timer expired, match should be starting...');
apiRequest('POST', '/match-timer/start-match') // Show notification that match is starting
.then(function(data) { showNotification('Match timer expired - starting next match...', 'info');
if (data.success) {
console.log('Match started successfully:', data); // Reload match interval config when timer reaches 0 and needs reset
showNotification('Match ' + data.match_number + ' started successfully', 'success'); loadMatchIntervalConfig().then(function(intervalSeconds) {
console.log('Reloaded match interval config for reset:', intervalSeconds, 'seconds');
// Reset timer to configured interval
apiRequest('GET', '/match-timer/config')
.then(function(configData) {
if (configData.success && configData.match_interval) {
resetMatchTimer(configData.match_interval * 60);
} else {
resetMatchTimer(20 * 60); // Fallback
}
})
.catch(function(error) {
console.error('Failed to get timer config for reset:', error);
resetMatchTimer(20 * 60); // Fallback
});
} else {
console.error('Failed to start match:', data.error);
showNotification('Failed to start match: ' + (data.error || 'Unknown error'), 'error');
// Stop timer if no matches are available
if (data.error && data.error.includes('No suitable fixture found')) {
stopMatchTimer();
updateMatchTimerDisplay();
showNotification('No matches available. Timer stopped.', 'info');
} else {
// Reset timer and try again later
resetMatchTimer(20 * 60);
}
}
})
.catch(function(error) {
console.error('Failed to start next match:', error);
showNotification('Error starting match: ' + error.message, 'error');
// Reset timer and continue // Reset timer with the (possibly updated) configuration
resetMatchTimer(20 * 60); serverTimerState.remaining_seconds = intervalSeconds;
serverTimerState.total_seconds = intervalSeconds;
// Sync with server to get updated state
setTimeout(function() {
syncWithServerTimer();
}, 2000); // Wait 2 seconds for server to process
}); });
} }
function onStartGameMessage() {
console.log('Received START_GAME message, initializing timer...');
// Load config and start timer when games start
loadMatchIntervalConfig().then(function(intervalSeconds) {
console.log('Loaded config for game start:', intervalSeconds, 'seconds');
// Set timer state for the first time
serverTimerState.running = true;
serverTimerState.remaining_seconds = intervalSeconds;
serverTimerState.total_seconds = intervalSeconds;
// Update display
updateMatchTimerDisplay();
startLocalCountdown();
showNotification('Games started - match timer is now running', 'success');
});
}
// Legacy functions for backward compatibility
function startMatchTimer() {
startServerTimer();
}
function stopMatchTimer() {
stopServerTimer();
}
function resetMatchTimer(seconds) {
// This is now handled by the server
syncWithServerTimer();
}
function startNextMatch() {
// This is now handled by the server-side timer
console.log('startNextMatch called - now handled by server-side timer');
}
// Utility functions // Utility functions
function formatBytes(bytes, decimals) { function formatBytes(bytes, decimals) {
......
...@@ -365,12 +365,20 @@ ...@@ -365,12 +365,20 @@
case 'GAME_STATUS': case 'GAME_STATUS':
handleGameStatus(data, timestamp); handleGameStatus(data, timestamp);
break; break;
case 'TIMER_UPDATE':
handleTimerUpdate(data, timestamp);
break;
case 'FIXTURE_STATUS_UPDATE':
handleFixtureStatusUpdate(data, timestamp);
break;
default: default:
console.log('Unknown notification type:', type); console.log('Unknown notification type:', type);
} }
// Show notification to user // Show notification to user (except for timer and fixture status updates)
showNotificationToast(type, data); if (type !== 'TIMER_UPDATE' && type !== 'FIXTURE_STATUS_UPDATE') {
showNotificationToast(type, data);
}
} }
function handleStartGame(data, timestamp) { function handleStartGame(data, timestamp) {
...@@ -383,6 +391,11 @@ ...@@ -383,6 +391,11 @@
matchTimerElement.className = 'badge bg-success'; matchTimerElement.className = 'badge bg-success';
} }
// Trigger timer initialization in Dashboard.js if available
if (typeof Dashboard !== 'undefined' && Dashboard.onStartGameMessage) {
Dashboard.onStartGameMessage();
}
// Trigger custom event for page-specific handling // Trigger custom event for page-specific handling
const event = new CustomEvent('startGame', { const event = new CustomEvent('startGame', {
detail: { data, timestamp } detail: { data, timestamp }
...@@ -400,6 +413,11 @@ ...@@ -400,6 +413,11 @@
matchTimerElement.className = 'badge bg-primary'; matchTimerElement.className = 'badge bg-primary';
} }
// Force sync with match timer component
if (typeof Dashboard !== 'undefined' && Dashboard.syncWithServerTimer) {
Dashboard.syncWithServerTimer();
}
// Trigger custom event for page-specific handling // Trigger custom event for page-specific handling
const event = new CustomEvent('matchStart', { const event = new CustomEvent('matchStart', {
detail: { data, timestamp } detail: { data, timestamp }
...@@ -421,6 +439,13 @@ ...@@ -421,6 +439,13 @@
systemStatusElement.className = `badge bg-${getStatusColor(status)}`; systemStatusElement.className = `badge bg-${getStatusColor(status)}`;
} }
// If this is a timer-related status, sync with timer
if (status === 'started' && data.action === 'refresh') {
if (typeof Dashboard !== 'undefined' && Dashboard.syncWithServerTimer) {
Dashboard.syncWithServerTimer();
}
}
// Trigger custom event for page-specific handling // Trigger custom event for page-specific handling
const event = new CustomEvent('gameStatus', { const event = new CustomEvent('gameStatus', {
detail: { data, timestamp } detail: { data, timestamp }
...@@ -428,6 +453,112 @@ ...@@ -428,6 +453,112 @@
document.dispatchEvent(event); document.dispatchEvent(event);
} }
function handleTimerUpdate(data, timestamp) {
console.log('Handling TIMER_UPDATE notification:', data);
// Update timer displays directly from server data ONLY
if (data.running !== undefined && data.remaining_seconds !== undefined) {
// Clear any existing client-side timers
if (window.clientTimerInterval) {
clearInterval(window.clientTimerInterval);
window.clientTimerInterval = null;
}
// Update all timer elements with server data
updateTimerElements(data.remaining_seconds, data.running);
// Store server data for potential interpolation
window.lastServerUpdate = {
remaining_seconds: data.remaining_seconds,
timestamp: Date.now(),
running: data.running
};
// Update timer status text
const timerStatus = document.getElementById('timer-status');
if (timerStatus) {
if (data.running) {
timerStatus.textContent = 'Timer running';
timerStatus.className = 'text-success';
} else {
timerStatus.textContent = 'Waiting for games to start...';
timerStatus.className = 'text-muted';
}
}
// Trigger custom event for page-specific handling
const event = new CustomEvent('timerUpdate', {
detail: { data, timestamp }
});
document.dispatchEvent(event);
}
}
function handleFixtureStatusUpdate(data, timestamp) {
console.log('Handling FIXTURE_STATUS_UPDATE notification:', data);
// Trigger automatic refresh if on fixtures page
const currentPage = window.location.pathname;
if (currentPage === '/fixtures' || currentPage.startsWith('/fixtures/')) {
// Check if loadFixtures function exists (fixtures page)
if (typeof loadFixtures === 'function') {
console.log('Auto-refreshing fixtures due to status update');
loadFixtures();
}
// Check if loadFixtureDetails function exists (fixture details page)
if (typeof loadFixtureDetails === 'function') {
console.log('Auto-refreshing fixture details due to status update');
loadFixtureDetails();
}
}
// Trigger custom event for page-specific handling
const event = new CustomEvent('fixtureStatusUpdate', {
detail: { data, timestamp }
});
document.dispatchEvent(event);
}
function updateTimerElements(seconds, isRunning) {
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
const timeString = minutes.toString().padStart(2, '0') + ':' + remainingSeconds.toString().padStart(2, '0');
console.log(`Updating timer elements: ${timeString} (${seconds}s, running: ${isRunning})`);
// Update status bar timer
const statusTimer = document.getElementById('match-timer');
if (statusTimer) {
statusTimer.textContent = timeString;
if (!isRunning) {
statusTimer.className = 'badge bg-warning text-dark';
statusTimer.style.animation = '';
} else if (seconds <= 60) { // Last minute
statusTimer.className = 'badge bg-danger text-white';
statusTimer.style.animation = 'timerPulse 0.5s infinite';
} else if (seconds <= 300) { // Last 5 minutes
statusTimer.className = 'badge bg-warning text-dark';
statusTimer.style.animation = '';
} else {
statusTimer.className = 'badge bg-success text-white';
statusTimer.style.animation = '';
}
}
// Update admin dashboard timer if present
const adminTimer = document.getElementById('admin-match-timer');
if (adminTimer) {
adminTimer.textContent = timeString;
if (isRunning) {
adminTimer.className = seconds <= 60 ? 'h3 mb-2 text-danger fw-bold' : 'h3 mb-2 text-success fw-bold';
} else {
adminTimer.className = 'h3 mb-2 text-muted';
}
}
}
function showNotificationToast(type, data) { function showNotificationToast(type, data) {
// Create and show a toast notification // Create and show a toast notification
const toastHtml = ` const toastHtml = `
......
<!DOCTYPE html> {% extends "base.html" %}
<html lang="en">
<head> {% block content %}
<meta charset="UTF-8"> <div class="row">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <div class="col-12">
<title>Cashier Dashboard - {{ app_name }}</title> <h1 class="mb-4">
<i class="fas fa-cash-register me-2"></i>Cashier Dashboard
<!-- CSS from CDN --> <small class="text-muted">Welcome, {{ current_user.username }}</small>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css"> </h1>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css"> </div>
<link rel="stylesheet" href="{{ url_for('static', filename='css/dashboard.css') }}"> </div>
<style> <!-- Quick Actions for Cashier -->
.navbar-clock { <div class="row mb-4">
background: rgba(255, 255, 255, 0.1); <div class="col-12">
border: 2px solid rgba(255, 255, 255, 0.3); <div class="card">
border-radius: 8px; <div class="card-header">
padding: 8px 16px; <h5 class="card-title mb-0">
font-family: 'Courier New', monospace; <i class="fas fa-bolt me-2"></i>Cashier Controls
font-weight: bold; </h5>
font-size: 1.2rem; </div>
color: #ffffff; <div class="card-body">
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.5); <div class="row">
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); <div class="col-md-4 mb-3">
transition: all 0.3s ease; <button class="btn btn-primary w-100 fw-bold" id="btn-start-games">
} <i class="fas fa-gamepad me-2"></i>Start Games
</button>
.navbar-clock:hover {
background: rgba(255, 255, 255, 0.2);
border-color: rgba(255, 255, 255, 0.5);
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
}
.navbar-clock i {
color: #ffffff;
filter: drop-shadow(1px 1px 1px rgba(0, 0, 0, 0.5));
}
#clock-time {
font-size: 1.3rem;
letter-spacing: 2px;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.navbar-clock {
font-size: 1rem;
padding: 6px 12px;
}
#clock-time {
font-size: 1.1rem;
letter-spacing: 1px;
}
}
</style>
</head>
<body>
<!-- Simplified Navigation Bar for Cashier -->
<nav class="navbar navbar-expand-lg navbar-dark bg-primary">
<div class="container-fluid">
<a class="navbar-brand" href="#">
<i class="fas fa-cash-register me-2"></i>{{ app_name }} - Cashier
</a>
<ul class="navbar-nav ms-auto">
<!-- Digital Clock -->
<li class="nav-item d-flex align-items-center me-3">
<div id="digital-clock" class="navbar-clock">
<i class="fas fa-clock me-2"></i>
<span id="clock-time">--:--:--</span>
</div>
</li>
<!-- Match Timer -->
<li class="nav-item d-flex align-items-center me-3">
<div id="match-timer-navbar" class="navbar-timer">
<i class="fas fa-stopwatch me-2"></i>
<span id="match-timer-display">--:--</span>
</div> </div>
</li> <div class="col-md-4 mb-3">
<li class="nav-item"> <button class="btn btn-outline-primary w-100" id="btn-play-video">
<a class="nav-link" href="{{ url_for('auth.logout') }}"> <i class="fas fa-play me-2"></i>Start Video Display
<i class="fas fa-sign-out-alt me-1"></i>Logout </button>
</a>
</li>
</ul>
</div>
</nav>
<main class="container-fluid mt-4">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ 'danger' if category == 'error' else category }} alert-dismissible fade show" role="alert">
{% if category == 'error' %}
<i class="fas fa-exclamation-triangle me-2"></i>
{% elif category == 'success' %}
<i class="fas fa-check-circle me-2"></i>
{% elif category == 'info' %}
<i class="fas fa-info-circle me-2"></i>
{% endif %}
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div> </div>
{% endfor %} <div class="col-md-4 mb-3">
{% endif %} <button class="btn btn-outline-success w-100" id="btn-update-overlay">
{% endwith %} <i class="fas fa-edit me-2"></i>Update Display Overlay
</button>
<div class="row">
<div class="col-12">
<h1 class="mb-4">
<i class="fas fa-cash-register me-2"></i>Cashier Dashboard
<small class="text-muted">Welcome, {{ current_user.username }}</small>
</h1>
</div>
</div>
<!-- Quick Actions for Cashier -->
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">
<i class="fas fa-bolt me-2"></i>Cashier Controls
</h5>
</div> </div>
<div class="card-body"> <div class="col-md-4 mb-3">
<div class="row"> <button class="btn btn-outline-info w-100" id="btn-refresh-matches">
<div class="col-md-4 mb-3"> <i class="fas fa-sync-alt me-2"></i>Refresh Matches
<button class="btn btn-primary w-100 fw-bold" id="btn-start-games"> </button>
<i class="fas fa-gamepad me-2"></i>Start Games
</button>
</div>
<div class="col-md-4 mb-3">
<button class="btn btn-outline-primary w-100" id="btn-play-video">
<i class="fas fa-play me-2"></i>Start Video Display
</button>
</div>
<div class="col-md-4 mb-3">
<button class="btn btn-outline-success w-100" id="btn-update-overlay">
<i class="fas fa-edit me-2"></i>Update Display Overlay
</button>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div>
<!-- Main Content Layout --> </div>
<div class="row">
<!-- Pending Matches from First Fixture - Left Side --> <!-- Main Content Layout -->
<div class="col-lg-9 col-md-8"> <div class="row">
<div class="card mb-4"> <!-- Pending Matches from First Fixture - Left Side -->
<div class="card-header"> <div class="col-lg-9 col-md-8">
<h5 class="card-title mb-0"> <div class="card mb-4">
<i class="fas fa-list me-2"></i>Pending Matches - First Fixture <div class="card-header">
<span class="badge bg-warning ms-2" id="pending-matches-count">0</span> <h5 class="card-title mb-0">
</h5> <i class="fas fa-list me-2"></i>Pending Matches - First Fixture
</div> <span class="badge bg-warning ms-2" id="pending-matches-count">0</span>
<div class="card-body"> </h5>
<div id="pending-matches-container"> </div>
<div class="text-center text-muted"> <div class="card-body">
<i class="fas fa-spinner fa-spin me-2"></i>Loading pending matches... <div id="pending-matches-container">
</div> <div class="text-center text-muted">
</div> <i class="fas fa-spinner fa-spin me-2"></i>Loading pending matches...
</div> </div>
</div> </div>
</div> </div>
</div>
</div>
<!-- Right Column --> <!-- Right Column -->
<div class="col-lg-3 col-md-4"> <div class="col-lg-3 col-md-4">
<!-- Current Display Status - Smaller and Compact --> <!-- Current Display Status - Smaller and Compact -->
<div class="card mb-4"> <div class="card mb-4">
<div class="card-header"> <div class="card-header">
<h6 class="card-title mb-0"> <h6 class="card-title mb-0">
<i class="fas fa-desktop me-2"></i>Display Status <i class="fas fa-desktop me-2"></i>Display Status
</h6> </h6>
</div> </div>
<div class="card-body p-3"> <div class="card-body p-3">
<div class="d-flex flex-column"> <div class="d-flex flex-column">
<!-- Video Status --> <!-- Video Status -->
<div class="text-center mb-3"> <div class="text-center mb-3">
<div class="d-flex flex-column align-items-center"> <div class="d-flex flex-column align-items-center">
<i class="fas fa-video text-primary mb-1" style="font-size: 1.5rem;"></i> <i class="fas fa-video text-primary mb-1" style="font-size: 1.5rem;"></i>
<small class="text-primary fw-bold" id="video-status-text">Stopped</small> <small class="text-primary fw-bold" id="video-status-text">Stopped</small>
<small class="text-muted">Video Status</small> <small class="text-muted">Video Status</small>
</div>
</div>
<!-- Overlay Status -->
<div class="text-center">
<div class="d-flex flex-column align-items-center">
<i class="fas fa-layer-group text-success mb-1" style="font-size: 1.5rem;"></i>
<small class="text-success fw-bold" id="overlay-status-text">Ready</small>
<small class="text-muted">Overlay Status</small>
</div>
</div>
</div> </div>
</div>
<div class="small"> <!-- Overlay Status -->
<div class="d-flex justify-content-between mb-1"> <div class="text-center">
<span class="text-muted">Video:</span> <div class="d-flex flex-column align-items-center">
<small id="current-video-path" class="text-truncate" style="max-width: 120px;">No video loaded</small> <i class="fas fa-layer-group text-success mb-1" style="font-size: 1.5rem;"></i>
</div> <small class="text-success fw-bold" id="overlay-status-text">Ready</small>
<div class="d-flex justify-content-between"> <small class="text-muted">Overlay Status</small>
<span class="text-muted">Template:</span>
<small id="current-template-name">default</small>
</div>
</div> </div>
</div> </div>
</div> </div>
<!-- Betting Mode Configuration - Hidden for Cashier Users --> <div class="small">
<!-- This feature is only available to admin users --> <div class="d-flex justify-content-between mb-1">
<span class="text-muted">Video:</span>
<!-- Session Information --> <small id="current-video-path" class="text-truncate" style="max-width: 120px;">No video loaded</small>
<div class="card mb-4">
<div class="card-header">
<h6 class="card-title mb-0">
<i class="fas fa-user me-2"></i>Session Info
</h6>
</div> </div>
<div class="card-body p-3"> <div class="d-flex justify-content-between">
<dl class="mb-0 small"> <span class="text-muted">Template:</span>
<dt class="text-muted">User</dt> <small id="current-template-name">default</small>
<dd>{{ current_user.username }}</dd>
<dt class="text-muted">Role</dt>
<dd>
<span class="badge bg-info">Cashier</span>
</dd>
<dt class="text-muted">Login Time</dt>
<dd id="login-time">{{ current_user.last_login.strftime('%H:%M') if current_user.last_login else 'Just now' }}</dd>
<dt class="text-muted">System Status</dt>
<dd>
<span class="badge bg-success" id="system-status">Online</span>
</dd>
</dl>
</div> </div>
</div> </div>
</div>
</div>
<!-- Today's Activity --> <!-- Session Information -->
<div class="card"> <div class="card mb-4">
<div class="card-header"> <div class="card-header">
<h6 class="card-title mb-0"> <h6 class="card-title mb-0">
<i class="fas fa-chart-bar me-2"></i>Today's Activity <i class="fas fa-user me-2"></i>Session Info
</h6> </h6>
</div> </div>
<div class="card-body p-3 text-center"> <div class="card-body p-3">
<div class="row"> <dl class="mb-0 small">
<div class="col-6"> <dt class="text-muted">User</dt>
<h5 class="text-primary mb-1" id="videos-played">0</h5> <dd>{{ current_user.username }}</dd>
<small class="text-muted">Videos</small>
</div> <dt class="text-muted">Role</dt>
<div class="col-6"> <dd>
<h5 class="text-success mb-1" id="overlays-updated">0</h5> <span class="badge bg-info">Cashier</span>
<small class="text-muted">Overlays</small> </dd>
</div>
</div> <dt class="text-muted">Login Time</dt>
</div> <dd id="login-time">{{ current_user.last_login.strftime('%H:%M') if current_user.last_login else 'Just now' }}</dd>
</div>
<dt class="text-muted">System Status</dt>
<dd>
<span class="badge bg-success" id="system-status">Online</span>
</dd>
</dl>
</div> </div>
</div> </div>
</main>
<!-- Today's Activity -->
<!-- Video Control Modal --> <div class="card">
<div class="modal fade" id="playVideoModal" tabindex="-1"> <div class="card-header">
<div class="modal-dialog"> <h6 class="card-title mb-0">
<div class="modal-content"> <i class="fas fa-chart-bar me-2"></i>Today's Activity
<div class="modal-header"> </h6>
<h5 class="modal-title">Start Video Display</h5> </div>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button> <div class="card-body p-3 text-center">
</div> <div class="row">
<div class="modal-body"> <div class="col-6">
<form id="play-video-form"> <h5 class="text-primary mb-1" id="videos-played">0</h5>
<div class="mb-3"> <small class="text-muted">Videos</small>
<label class="form-label">Video File Path</label> </div>
<input type="text" class="form-control" id="video-file-path" <div class="col-6">
placeholder="/path/to/video.mp4"> <h5 class="text-success mb-1" id="overlays-updated">0</h5>
<div class="form-text">Enter the full path to the video file you want to display</div> <small class="text-muted">Overlays</small>
</div> </div>
<div class="mb-3">
<label class="form-label">Display Template</label>
<select class="form-select" id="video-template">
<option value="">Loading templates...</option>
</select>
<div class="form-text">Choose the template for displaying information over the video</div>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="confirm-play-video">
<i class="fas fa-play me-1"></i>Start Display
</button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div>
<!-- Overlay Update Modal -->
<div class="modal fade" id="updateOverlayModal" tabindex="-1"> <!-- Video Control Modal -->
<div class="modal-dialog"> <div class="modal fade" id="playVideoModal" tabindex="-1">
<div class="modal-content"> <div class="modal-dialog">
<div class="modal-header"> <div class="modal-content">
<h5 class="modal-title">Update Display Overlay</h5> <div class="modal-header">
<button type="button" class="btn-close" data-bs-dismiss="modal"></button> <h5 class="modal-title">Start Video Display</h5>
</div> <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
<div class="modal-body"> </div>
<form id="update-overlay-form"> <div class="modal-body">
<div class="mb-3"> <form id="play-video-form">
<label class="form-label">Display Template</label> <div class="mb-3">
<select class="form-select" id="overlay-template"> <label class="form-label">Video File Path</label>
<option value="">Loading templates...</option> <input type="text" class="form-control" id="video-file-path"
</select> placeholder="/path/to/video.mp4">
</div> <div class="form-text">Enter the full path to the video file you want to display</div>
<div class="mb-3"> </div>
<label class="form-label">Main Message</label> <div class="mb-3">
<input type="text" class="form-control" id="overlay-headline" <label class="form-label">Display Template</label>
placeholder="Special Offer"> <select class="form-select" id="video-template">
<div class="form-text">The main headline or title to display</div> <option value="">Loading templates...</option>
</div> </select>
<div class="mb-3"> <div class="form-text">Choose the template for displaying information over the video</div>
<label class="form-label">Additional Information</label> </div>
<textarea class="form-control" id="overlay-text" rows="3" </form>
placeholder="20% off all items today only!"></textarea> </div>
<div class="form-text">Additional text or details to show</div> <div class="modal-footer">
</div> <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
</form> <button type="button" class="btn btn-primary" id="confirm-play-video">
</div> <i class="fas fa-play me-1"></i>Start Display
<div class="modal-footer"> </button>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-success" id="confirm-update-overlay">
<i class="fas fa-edit me-1"></i>Update Display
</button>
</div>
</div> </div>
</div> </div>
</div> </div>
</div>
<!-- System Status Bar -->
<div id="status-bar" class="fixed-bottom bg-light border-top p-2 d-none d-lg-block"> <!-- Overlay Update Modal -->
<div class="container-fluid"> <div class="modal fade" id="updateOverlayModal" tabindex="-1">
<div class="row align-items-center text-small"> <div class="modal-dialog">
<div class="col-auto"> <div class="modal-content">
<span class="text-muted">Status:</span> <div class="modal-header">
<span id="system-status" class="badge bg-success">Online</span> <h5 class="modal-title">Update Display Overlay</h5>
</div> <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
<div class="col-auto"> </div>
<span class="text-muted">Video:</span> <div class="modal-body">
<span id="video-status" class="badge bg-secondary">Stopped</span> <form id="update-overlay-form">
</div> <div class="mb-3">
<div class="col-auto"> <label class="form-label">Display Template</label>
<span class="text-muted">Last Updated:</span> <select class="form-select" id="overlay-template">
<span id="last-updated" class="text-muted">--</span> <option value="">Loading templates...</option>
</div> </select>
<div class="col text-end"> </div>
<small class="text-muted">{{ app_name }} v{{ app_version }}</small> <div class="mb-3">
</div> <label class="form-label">Main Message</label>
<input type="text" class="form-control" id="overlay-headline"
placeholder="Special Offer">
<div class="form-text">The main headline or title to display</div>
</div>
<div class="mb-3">
<label class="form-label">Additional Information</label>
<textarea class="form-control" id="overlay-text" rows="3"
placeholder="20% off all items today only!"></textarea>
<div class="form-text">Additional text or details to show</div>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-success" id="confirm-update-overlay">
<i class="fas fa-edit me-1"></i>Update Display
</button>
</div> </div>
</div> </div>
</div> </div>
</div>
<!-- JavaScript from CDN and local --> {% endblock %}
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script src="{{ url_for('static', filename='js/dashboard.js') }}"></script> {% block scripts %}
<script>
<script id="dashboard-config" type="application/json"> document.addEventListener('DOMContentLoaded', function() {
{ // Load available templates on page load
"statusUpdateInterval": 30000, loadAvailableTemplates();
"apiEndpoint": "/api",
"user": { // Load pending matches for cashier dashboard
"id": {{ current_user.id | tojson }}, loadPendingMatches();
"username": {{ current_user.username | tojson }},
"is_admin": {{ current_user.is_admin | tojson }} // Quick action buttons
} document.getElementById('btn-start-games').addEventListener('click', function() {
// Show confirmation dialog for starting games
if (confirm('Are you sure you want to start the games? This will activate all pending matches.')) {
fetch('/api/cashier/start-games', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
action: 'start_games'
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
// Refresh the pending matches list
loadPendingMatches();
// Show success message
showNotification('Games started successfully!', 'success');
} else {
showNotification('Failed to start games: ' + (data.error || 'Unknown error'), 'error');
}
})
.catch(error => {
showNotification('Error starting games: ' + error.message, 'error');
});
} }
</script> });
<script>
// Initialize dashboard
document.addEventListener('DOMContentLoaded', function() {
if (typeof Dashboard !== 'undefined') {
var config = JSON.parse(document.getElementById('dashboard-config').textContent);
Dashboard.init(config);
}
// Initialize digital clock
initializeClock();
// Initialize long polling for notifications document.getElementById('btn-play-video').addEventListener('click', function() {
initializeNotifications(); new bootstrap.Modal(document.getElementById('playVideoModal')).show();
});
// Load pending matches for cashier dashboard document.getElementById('btn-update-overlay').addEventListener('click', function() {
loadPendingMatches(); new bootstrap.Modal(document.getElementById('updateOverlayModal')).show();
});
// Betting mode functionality removed for cashier users document.getElementById('btn-refresh-matches').addEventListener('click', function() {
}); console.log('🔄 Manual refresh button clicked');
loadPendingMatches();
});
function initializeClock() { // Confirm actions
const clockElement = document.getElementById('clock-time'); document.getElementById('confirm-play-video').addEventListener('click', function() {
if (!clockElement) return; const filePath = document.getElementById('video-file-path').value;
const template = document.getElementById('video-template').value;
let serverTimeOffset = 0; // Offset between server and client time
let lastServerTime = null;
function fetchServerTime() {
return fetch('/api/server-time')
.then(response => response.json())
.then(data => {
if (data.success) {
const serverTimestamp = data.timestamp;
const clientTimestamp = Date.now();
serverTimeOffset = serverTimestamp - clientTimestamp;
lastServerTime = serverTimestamp;
return serverTimestamp;
} else {
throw new Error('Failed to get server time');
}
})
.catch(error => {
console.error('Error fetching server time:', error);
// Fallback to client time if server time is unavailable
return Date.now();
});
}
function updateClock() { if (!filePath) {
const now = Date.now() + serverTimeOffset; alert('Please enter a video file path');
const date = new Date(now); return;
}
const hours = String(date.getHours()).padStart(2, '0'); fetch('/api/video/control', {
const minutes = String(date.getMinutes()).padStart(2, '0'); method: 'POST',
const seconds = String(date.getSeconds()).padStart(2, '0'); headers: {
const timeString = `${hours}:${minutes}:${seconds}`; 'Content-Type': 'application/json',
},
body: JSON.stringify({
action: 'play',
file_path: filePath,
template: template
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
bootstrap.Modal.getInstance(document.getElementById('playVideoModal')).hide();
updateVideoStatus();
updateCurrentVideoPath(filePath);
updateCurrentTemplate(template || 'default');
// Increment counter
const videosCount = parseInt(document.getElementById('videos-played').textContent) + 1;
document.getElementById('videos-played').textContent = videosCount;
} else {
alert('Failed to start video: ' + (data.error || 'Unknown error'));
}
})
.catch(error => {
alert('Error: ' + error.message);
});
});
clockElement.textContent = timeString; document.getElementById('confirm-update-overlay').addEventListener('click', function() {
const template = document.getElementById('overlay-template').value;
const headline = document.getElementById('overlay-headline').value;
const text = document.getElementById('overlay-text').value;
fetch('/api/overlay', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
template: template,
data: {
headline: headline,
ticker_text: text,
title: headline,
text: text
}
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
bootstrap.Modal.getInstance(document.getElementById('updateOverlayModal')).hide();
updateCurrentTemplate(template || 'default');
// Increment counter
const overlaysCount = parseInt(document.getElementById('overlays-updated').textContent) + 1;
document.getElementById('overlays-updated').textContent = overlaysCount;
// Show success message
showNotification('Overlay updated successfully!', 'success');
} else {
alert('Failed to update overlay: ' + (data.error || 'Unknown error'));
} }
})
.catch(error => {
alert('Error: ' + error.message);
});
});
// Fetch server time initially and set up updates // Status update functions
fetchServerTime().then(() => { function updateVideoStatus() {
// Update immediately with server time fetch('/api/video/status')
updateClock(); .then(response => response.json())
.then(data => {
const status = data.player_status || 'stopped';
document.getElementById('video-status-text').textContent =
status.charAt(0).toUpperCase() + status.slice(1);
})
.catch(error => {
document.getElementById('video-status-text').textContent = 'Unknown';
});
}
// Update display every second (using client time + offset) function updateCurrentVideoPath(path) {
setInterval(updateClock, 1000); const fileName = path.split('/').pop() || path;
document.getElementById('current-video-path').textContent = fileName;
}
// Sync with server time every 30 seconds function updateCurrentTemplate(template) {
setInterval(fetchServerTime, 30000); document.getElementById('current-template-name').textContent = template;
}); }
}
// Function to load and display pending matches function showNotification(message, type = 'info') {
function loadPendingMatches() { // Simple notification system - could be enhanced with toast notifications
fetch('/api/cashier/pending-matches') const alertClass = type === 'success' ? 'alert-success' : 'alert-info';
.then(response => response.json()) const notification = document.createElement('div');
.then(data => { notification.className = `alert ${alertClass} alert-dismissible fade show position-fixed`;
const container = document.getElementById('pending-matches-container'); notification.style.top = '20px';
const countBadge = document.getElementById('pending-matches-count'); notification.style.right = '20px';
notification.style.zIndex = '9999';
if (data.success) { notification.innerHTML = `
// Update count badge ${message}
countBadge.textContent = data.total; <button type="button" class="btn-close" data-bs-dismiss="alert"></button>
countBadge.className = data.total > 0 ? 'badge bg-warning ms-2' : 'badge bg-success ms-2'; `;
document.body.appendChild(notification);
if (data.total === 0) {
container.innerHTML = ` // Auto-remove after 3 seconds
<div class="text-center text-muted"> setTimeout(() => {
<i class="fas fa-check-circle me-2"></i>No pending matches found if (notification.parentNode) {
</div> notification.parentNode.removeChild(notification);
`; }
} else { }, 3000);
// Create matches table }
let tableHtml = `
<div class="table-responsive">
<table class="table table-striped table-hover">
<thead class="table-dark">
<tr>
<th><i class="fas fa-hashtag me-1"></i>Match #</th>
<th><i class="fas fa-user-friends me-1"></i>Fighter 1</th>
<th><i class="fas fa-user-friends me-1"></i>Fighter 2</th>
<th><i class="fas fa-map-marker-alt me-1"></i>Venue</th>
<th><i class="fas fa-clock me-1"></i>Start Time</th>
<th><i class="fas fa-info-circle me-1"></i>Status</th>
</tr>
</thead>
<tbody>
`;
data.matches.forEach(match => {
const startTime = match.start_time ?
new Date(match.start_time).toLocaleString() : 'Not scheduled';
// Get status from the match object (new status column)
const status = match.status || 'pending';
let statusBadge = '';
switch (status) {
case 'scheduled':
statusBadge = '<span class="badge bg-primary"><i class="fas fa-calendar-check me-1"></i>Scheduled</span>';
break;
case 'ingame':
statusBadge = '<span class="badge bg-info"><i class="fas fa-play me-1"></i>In Game</span>';
break;
case 'completed':
statusBadge = '<span class="badge bg-success"><i class="fas fa-check me-1"></i>Completed</span>';
break;
case 'cancelled':
statusBadge = '<span class="badge bg-secondary"><i class="fas fa-times me-1"></i>Cancelled</span>';
break;
case 'failed':
statusBadge = '<span class="badge bg-danger"><i class="fas fa-exclamation-triangle me-1"></i>Failed</span>';
break;
case 'paused':
statusBadge = '<span class="badge bg-warning"><i class="fas fa-pause me-1"></i>Paused</span>';
break;
default:
statusBadge = '<span class="badge bg-warning"><i class="fas fa-clock me-1"></i>Pending</span>';
}
tableHtml += `
<tr>
<td><strong>${match.match_number}</strong></td>
<td>${match.fighter1_township}</td>
<td>${match.fighter2_township}</td>
<td>${match.venue_kampala_township}</td>
<td>${startTime}</td>
<td>${statusBadge}</td>
</tr>
`;
});
tableHtml += `
</tbody>
</table>
</div>
`;
container.innerHTML = tableHtml;
}
} else {
container.innerHTML = `
<div class="text-center text-danger">
<i class="fas fa-exclamation-triangle me-2"></i>Error loading matches: ${data.error || 'Unknown error'}
</div>
`;
}
})
.catch(error => {
const container = document.getElementById('pending-matches-container');
container.innerHTML = `
<div class="text-center text-danger">
<i class="fas fa-exclamation-triangle me-2"></i>Error loading matches: ${error.message}
</div>
`;
});
}
// Add event listeners for real-time notifications // Initial status update
document.addEventListener('startGame', function(event) { updateVideoStatus();
console.log('Cashier dashboard: Received startGame event', event.detail);
// Update match timer display // Periodic status updates
const matchTimerElement = document.getElementById('match-timer-display'); setInterval(updateVideoStatus, 5000); // Every 5 seconds
if (matchTimerElement) { setInterval(loadPendingMatches, 5000); // Auto-refresh matches every 5 seconds
matchTimerElement.textContent = 'Starting...'; });
}
// Refresh pending matches to show updated status let cachedMatchesData = null;
loadPendingMatches(); let isInitialMatchLoad = true;
// Show notification to cashier // Function to load and display pending matches
showNotification('Games are starting! Match timer activated.', 'success'); function loadPendingMatches() {
}); console.log('🔍 loadPendingMatches() called');
document.addEventListener('matchStart', function(event) { const container = document.getElementById('pending-matches-container');
console.log('Cashier dashboard: Received matchStart event', event.detail); const countBadge = document.getElementById('pending-matches-count');
// Reset and start the match timer if (!container) {
const matchTimerElement = document.getElementById('match-timer-display'); console.error('❌ pending-matches-container not found');
if (matchTimerElement) { return;
matchTimerElement.textContent = '00:00'; }
// Start counting up from 00:00
startMatchTimer();
}
// Refresh pending matches to show updated status console.log('📡 Making API request to /api/cashier/pending-matches');
loadPendingMatches();
// Show notification to cashier // Only show loading state on initial load
showNotification('Match started! Timer is now running.', 'info'); if (isInitialMatchLoad) {
}); container.innerHTML = `
<div class="text-center text-muted">
<i class="fas fa-spinner fa-spin me-2"></i>Loading pending matches...
</div>
`;
}
document.addEventListener('gameStatus', function(event) { fetch('/api/cashier/pending-matches')
console.log('Cashier dashboard: Received gameStatus event', event.detail); .then(response => {
console.log('📡 API response status:', response.status);
if (!response.ok) {
throw new Error('API request failed: ' + response.status);
}
return response.json();
})
.then(data => {
console.log('📦 API response data:', data);
if (data.success) {
// Check if data has actually changed
const currentDataString = JSON.stringify(data);
if (cachedMatchesData === currentDataString && !isInitialMatchLoad) {
console.log('📦 No changes in match data, skipping update');
return;
}
cachedMatchesData = currentDataString;
// Refresh pending matches to show updated status // Update count badge
loadPendingMatches(); countBadge.textContent = data.total;
countBadge.className = data.total > 0 ? 'badge bg-warning ms-2' : 'badge bg-success ms-2';
// Update system status if present updateMatchesTable(data, container);
const systemStatusElement = document.getElementById('system-status'); } else {
if (systemStatusElement && event.detail.data.status) { if (isInitialMatchLoad) {
systemStatusElement.textContent = event.detail.data.status; container.innerHTML = `
systemStatusElement.className = `badge bg-${getStatusColor(event.detail.data.status)}`; <div class="text-center text-danger">
<i class="fas fa-exclamation-triangle me-2"></i>Error loading matches: ${data.error || 'Unknown error'}
</div>
`;
}
}
})
.catch(error => {
console.error('❌ Error loading matches:', error);
if (isInitialMatchLoad) {
container.innerHTML = `
<div class="text-center text-danger">
<i class="fas fa-exclamation-triangle me-2"></i>Error loading matches: ${error.message}
</div>
`;
} }
})
.finally(() => {
isInitialMatchLoad = false;
}); });
}
function startMatchTimer() { function updateMatchesTable(data, container) {
let seconds = 0; if (data.total === 0) {
const matchTimerElement = document.getElementById('match-timer-display'); container.innerHTML = `
<div class="text-center text-muted">
if (!matchTimerElement) return; <i class="fas fa-check-circle me-2"></i>No pending matches found
</div>
`;
return;
}
const timer = setInterval(() => { // Check if table already exists
seconds++; let tbody = container.querySelector('tbody');
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60; if (!tbody) {
const timeString = `${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`; // Create new table
matchTimerElement.textContent = timeString; container.innerHTML = `
}, 1000); <div class="table-responsive">
<table class="table table-striped table-hover">
<thead class="table-dark">
<tr>
<th><i class="fas fa-hashtag me-1"></i>Match #</th>
<th><i class="fas fa-user-friends me-1"></i>Fighter 1</th>
<th><i class="fas fa-user-friends me-1"></i>Fighter 2</th>
<th><i class="fas fa-map-marker-alt me-1"></i>Venue</th>
<th><i class="fas fa-clock me-1"></i>Start Time</th>
<th><i class="fas fa-info-circle me-1"></i>Status</th>
</tr>
</thead>
<tbody>
</tbody>
</table>
</div>
`;
tbody = container.querySelector('tbody');
}
// Store timer reference for cleanup if needed // Update table rows intelligently
matchTimerElement.dataset.timerId = timer; const existingRows = Array.from(tbody.children);
const existingMatches = new Map();
existingRows.forEach(row => {
const matchNumber = row.getAttribute('data-match-number');
if (matchNumber) {
existingMatches.set(matchNumber, row);
} }
});
function getStatusColor(status) { const processedMatches = new Set();
switch (status) {
case 'running': return 'success';
case 'scheduled': return 'warning';
case 'pending': return 'secondary';
case 'completed': return 'info';
case 'error': return 'danger';
default: return 'secondary';
}
}
</script>
<script> data.matches.forEach(match => {
document.addEventListener('DOMContentLoaded', function() { const matchNumber = match.match_number.toString();
// Load available templates on page load processedMatches.add(matchNumber);
loadAvailableTemplates();
// Quick action buttons const startTime = match.start_time ?
document.getElementById('btn-start-games').addEventListener('click', function() { new Date(match.start_time).toLocaleString() : 'Not scheduled';
// Show confirmation dialog for starting games
if (confirm('Are you sure you want to start the games? This will activate all pending matches.')) { const status = match.status || 'pending';
fetch('/api/cashier/start-games', { let statusBadge = '';
method: 'POST',
headers: { switch (status) {
'Content-Type': 'application/json', case 'scheduled':
}, statusBadge = '<span class="badge bg-primary"><i class="fas fa-calendar-check me-1"></i>Scheduled</span>';
body: JSON.stringify({ break;
action: 'start_games' case 'bet':
}) statusBadge = '<span class="badge bg-success"><i class="fas fa-dollar-sign me-1"></i>Bet</span>';
}) break;
.then(response => response.json()) case 'ingame':
.then(data => { statusBadge = '<span class="badge bg-info"><i class="fas fa-play me-1"></i>In Game</span>';
if (data.success) { break;
alert('Games started successfully!'); case 'completed':
// Refresh the pending matches list statusBadge = '<span class="badge bg-success"><i class="fas fa-check me-1"></i>Completed</span>';
loadPendingMatches(); break;
// Show success message case 'cancelled':
showNotification('Games started successfully!', 'success'); statusBadge = '<span class="badge bg-secondary"><i class="fas fa-times me-1"></i>Cancelled</span>';
} else { break;
alert('Failed to start games: ' + (data.error || 'Unknown error')); case 'failed':
} statusBadge = '<span class="badge bg-danger"><i class="fas fa-exclamation-triangle me-1"></i>Failed</span>';
}) break;
.catch(error => { case 'paused':
alert('Error starting games: ' + error.message); statusBadge = '<span class="badge bg-warning"><i class="fas fa-pause me-1"></i>Paused</span>';
}); break;
} default:
}); statusBadge = '<span class="badge bg-warning"><i class="fas fa-clock me-1"></i>Pending</span>';
}
document.getElementById('btn-play-video').addEventListener('click', function() {
new bootstrap.Modal(document.getElementById('playVideoModal')).show();
});
document.getElementById('btn-update-overlay').addEventListener('click', function() { const newRowHTML = `
new bootstrap.Modal(document.getElementById('updateOverlayModal')).show(); <td><strong>${match.match_number}</strong></td>
}); <td>${match.fighter1_township}</td>
<td>${match.fighter2_township}</td>
// Confirm actions <td>${match.venue_kampala_township}</td>
document.getElementById('confirm-play-video').addEventListener('click', function() { <td>${startTime}</td>
const filePath = document.getElementById('video-file-path').value; <td>${statusBadge}</td>
const template = document.getElementById('video-template').value; `;
if (!filePath) { const existingRow = existingMatches.get(matchNumber);
alert('Please enter a video file path'); if (existingRow) {
return; // Update existing row only if content changed
if (existingRow.innerHTML !== newRowHTML) {
existingRow.innerHTML = newRowHTML;
existingRow.style.backgroundColor = '#fff3cd'; // Highlight briefly
setTimeout(() => {
existingRow.style.backgroundColor = '';
}, 1000);
} }
} else {
fetch('/api/video/control', { // Add new row
method: 'POST', const row = document.createElement('tr');
headers: { row.setAttribute('data-match-number', matchNumber);
'Content-Type': 'application/json', row.innerHTML = newRowHTML;
}, row.style.backgroundColor = '#d4edda'; // Highlight new row
body: JSON.stringify({ tbody.appendChild(row);
action: 'play', setTimeout(() => {
file_path: filePath, row.style.backgroundColor = '';
template: template }, 1000);
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
bootstrap.Modal.getInstance(document.getElementById('playVideoModal')).hide();
updateVideoStatus();
updateCurrentVideoPath(filePath);
updateCurrentTemplate(template || 'default');
// Increment counter
const videosCount = parseInt(document.getElementById('videos-played').textContent) + 1;
document.getElementById('videos-played').textContent = videosCount;
} else {
alert('Failed to start video: ' + (data.error || 'Unknown error'));
}
})
.catch(error => {
alert('Error: ' + error.message);
});
});
document.getElementById('confirm-update-overlay').addEventListener('click', function() {
const template = document.getElementById('overlay-template').value;
const headline = document.getElementById('overlay-headline').value;
const text = document.getElementById('overlay-text').value;
fetch('/api/overlay', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
template: template,
data: {
headline: headline,
ticker_text: text,
title: headline,
text: text
}
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
bootstrap.Modal.getInstance(document.getElementById('updateOverlayModal')).hide();
updateCurrentTemplate(template || 'default');
// Increment counter
const overlaysCount = parseInt(document.getElementById('overlays-updated').textContent) + 1;
document.getElementById('overlays-updated').textContent = overlaysCount;
// Show success message
showNotification('Overlay updated successfully!', 'success');
} else {
alert('Failed to update overlay: ' + (data.error || 'Unknown error'));
}
})
.catch(error => {
alert('Error: ' + error.message);
});
});
// Status update functions
function updateVideoStatus() {
fetch('/api/video/status')
.then(response => response.json())
.then(data => {
const status = data.player_status || 'stopped';
document.getElementById('video-status-text').textContent =
status.charAt(0).toUpperCase() + status.slice(1);
})
.catch(error => {
document.getElementById('video-status-text').textContent = 'Unknown';
});
}
function updateCurrentVideoPath(path) {
const fileName = path.split('/').pop() || path;
document.getElementById('current-video-path').textContent = fileName;
}
function updateCurrentTemplate(template) {
document.getElementById('current-template-name').textContent = template;
} }
});
function showNotification(message, type = 'info') {
// Simple notification system - could be enhanced with toast notifications // Remove rows no longer in data
const alertClass = type === 'success' ? 'alert-success' : 'alert-info'; existingMatches.forEach((row, matchNumber) => {
const notification = document.createElement('div'); if (!processedMatches.has(matchNumber)) {
notification.className = `alert ${alertClass} alert-dismissible fade show position-fixed`; row.style.backgroundColor = '#f8d7da'; // Highlight removed row
notification.style.top = '20px';
notification.style.right = '20px';
notification.style.zIndex = '9999';
notification.innerHTML = `
${message}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
`;
document.body.appendChild(notification);
// Auto-remove after 3 seconds
setTimeout(() => { setTimeout(() => {
if (notification.parentNode) { if (row.parentNode) {
notification.parentNode.removeChild(notification); row.parentNode.removeChild(row);
} }
}, 3000); }, 500);
} }
// Initial status update
updateVideoStatus();
// Periodic status updates
setInterval(updateVideoStatus, 10000); // Every 10 seconds
}); });
}
function loadAvailableTemplates() {
fetch('/api/templates') function loadAvailableTemplates() {
.then(response => response.json()) fetch('/api/templates')
.then(data => { .then(response => response.json())
const videoTemplateSelect = document.getElementById('video-template'); .then(data => {
const overlayTemplateSelect = document.getElementById('overlay-template'); const videoTemplateSelect = document.getElementById('video-template');
const overlayTemplateSelect = document.getElementById('overlay-template');
// Clear loading options
videoTemplateSelect.innerHTML = ''; // Clear loading options
overlayTemplateSelect.innerHTML = ''; videoTemplateSelect.innerHTML = '';
overlayTemplateSelect.innerHTML = '';
if (data.templates && Array.isArray(data.templates)) {
data.templates.forEach(template => { if (data.templates && Array.isArray(data.templates)) {
// Add to video template select data.templates.forEach(template => {
const videoOption = document.createElement('option'); // Add to video template select
videoOption.value = template.name;
videoOption.textContent = template.display_name || template.name;
videoTemplateSelect.appendChild(videoOption);
// Add to overlay template select
const overlayOption = document.createElement('option');
overlayOption.value = template.name;
overlayOption.textContent = template.display_name || template.name;
overlayTemplateSelect.appendChild(overlayOption);
});
// Select default template if available
const defaultVideoOption = videoTemplateSelect.querySelector('option[value="default"]');
if (defaultVideoOption) {
defaultVideoOption.selected = true;
}
const defaultOverlayOption = overlayTemplateSelect.querySelector('option[value="default"]');
if (defaultOverlayOption) {
defaultOverlayOption.selected = true;
}
} else {
// Fallback if no templates found
const videoOption = document.createElement('option'); const videoOption = document.createElement('option');
videoOption.value = 'default'; videoOption.value = template.name;
videoOption.textContent = 'Default'; videoOption.textContent = template.display_name || template.name;
videoTemplateSelect.appendChild(videoOption); videoTemplateSelect.appendChild(videoOption);
// Add to overlay template select
const overlayOption = document.createElement('option'); const overlayOption = document.createElement('option');
overlayOption.value = 'default'; overlayOption.value = template.name;
overlayOption.textContent = 'Default'; overlayOption.textContent = template.display_name || template.name;
overlayTemplateSelect.appendChild(overlayOption); overlayTemplateSelect.appendChild(overlayOption);
});
// Select default template if available
const defaultVideoOption = videoTemplateSelect.querySelector('option[value="default"]');
if (defaultVideoOption) {
defaultVideoOption.selected = true;
} }
})
.catch(error => {
console.error('Error loading templates:', error);
// Fallback template options
const videoTemplateSelect = document.getElementById('video-template');
const overlayTemplateSelect = document.getElementById('overlay-template');
videoTemplateSelect.innerHTML = '<option value="default">Default</option>';
overlayTemplateSelect.innerHTML = '<option value="default">Default</option>';
});
}
// Betting mode functions removed - not available for cashier users const defaultOverlayOption = overlayTemplateSelect.querySelector('option[value="default"]');
</script> if (defaultOverlayOption) {
</body> defaultOverlayOption.selected = true;
</html> }
\ No newline at end of file } else {
// Fallback if no templates found
const videoOption = document.createElement('option');
videoOption.value = 'default';
videoOption.textContent = 'Default';
videoTemplateSelect.appendChild(videoOption);
const overlayOption = document.createElement('option');
overlayOption.value = 'default';
overlayOption.textContent = 'Default';
overlayTemplateSelect.appendChild(overlayOption);
}
})
.catch(error => {
console.error('Error loading templates:', error);
// Fallback template options
const videoTemplateSelect = document.getElementById('video-template');
const overlayTemplateSelect = document.getElementById('overlay-template');
videoTemplateSelect.innerHTML = '<option value="default">Default</option>';
overlayTemplateSelect.innerHTML = '<option value="default">Default</option>';
});
}
</script>
<!-- Include the main dashboard.js for timer functionality -->
<script src="{{ url_for('static', filename='js/dashboard.js') }}"></script>
<script>
// Initialize dashboard with timer functionality
document.addEventListener('DOMContentLoaded', function() {
// Initialize Dashboard with timer support
if (typeof Dashboard !== 'undefined') {
Dashboard.init({
user: {
username: '{{ current_user.username }}',
role: 'cashier'
}
});
}
// Hook into the start games button to trigger timer notifications
const startGamesBtn = document.getElementById('btn-start-games');
if (startGamesBtn) {
startGamesBtn.addEventListener('click', function() {
// The existing START_GAME logic will trigger the timer
// Dashboard.js will automatically sync with the server timer
});
}
});
</script>
{% endblock %}
...@@ -215,6 +215,27 @@ ...@@ -215,6 +215,27 @@
</div> </div>
</div> </div>
<!-- Betting Mode Settings -->
<div class="card mb-4">
<div class="card-header">
<h5>Betting Mode Settings</h5>
</div>
<div class="card-body">
<form id="betting-mode-config-form">
<div class="mb-3">
<label for="betting-mode" class="form-label">Betting Mode</label>
<select class="form-select" id="betting-mode">
<option value="all_bets_on_start">All Bets on Start - Place all bets when games begin</option>
<option value="one_bet_at_a_time">One Bet at a Time - Place bets individually</option>
</select>
<div class="form-text">Choose how bets are placed during games</div>
</div>
<button type="submit" class="btn btn-primary">Save Betting Mode</button>
</form>
<div id="betting-mode-status" class="mt-3"></div>
</div>
</div>
<!-- API Client Debug Section --> <!-- API Client Debug Section -->
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
...@@ -456,5 +477,74 @@ ...@@ -456,5 +477,74 @@
this.textContent = 'Trigger Manual Request'; this.textContent = 'Trigger Manual Request';
}); });
}); });
// Load current betting mode on page load
document.addEventListener('DOMContentLoaded', function() {
loadBettingMode();
});
// Load betting mode configuration
function loadBettingMode() {
fetch('/api/betting-mode')
.then(response => response.json())
.then(data => {
if (data.success) {
document.getElementById('betting-mode').value = data.betting_mode || 'all_bets_on_start';
if (data.is_default) {
document.getElementById('betting-mode-status').innerHTML =
'<div class="alert alert-info"><small>Using default betting mode</small></div>';
}
} else {
console.error('Failed to load betting mode:', data.error);
document.getElementById('betting-mode-status').innerHTML =
'<div class="alert alert-warning"><small>Could not load current betting mode</small></div>';
}
})
.catch(error => {
console.error('Error loading betting mode:', error);
document.getElementById('betting-mode-status').innerHTML =
'<div class="alert alert-warning"><small>Error loading betting mode</small></div>';
});
}
// Save betting mode configuration
document.getElementById('betting-mode-config-form').addEventListener('submit', function(e) {
e.preventDefault();
const bettingMode = document.getElementById('betting-mode').value;
const statusDiv = document.getElementById('betting-mode-status');
// Clear previous status
statusDiv.innerHTML = '';
// Save betting mode
fetch('/api/betting-mode', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
betting_mode: bettingMode
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
statusDiv.innerHTML = '<div class="alert alert-success">Betting mode saved successfully: ' + data.message + '</div>';
// Auto-hide success message after 3 seconds
setTimeout(() => {
statusDiv.innerHTML = '';
}, 3000);
} else {
statusDiv.innerHTML = '<div class="alert alert-danger">Failed to save betting mode: ' + data.error + '</div>';
}
})
.catch(error => {
console.error('Error saving betting mode:', error);
statusDiv.innerHTML = '<div class="alert alert-danger">Error saving betting mode: ' + error.message + '</div>';
});
});
</script> </script>
{% endblock %} {% endblock %}
\ No newline at end of file
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}{{ page_title }}{% endblock %}
{% block content %} {% block content %}
<div class="container-fluid"> <div class="row">
<div class="row"> <div class="col-12">
<div class="col-12"> <h1 class="mb-4">
<div class="d-flex justify-content-between align-items-center mb-3"> <i class="fas fa-cogs me-2"></i>Extraction Management
<div> <small class="text-muted">Configure outcome associations and time limits</small>
<h1><i class="fas fa-cogs me-2"></i>Extraction Management</h1> </h1>
<p class="mb-0">Configure outcome associations for extraction results and manage game settings.</p> </div>
</div> </div>
<div>
<button type="button" class="btn btn-primary" id="save-associations-btn">
<i class="fas fa-save me-1"></i>Save Associations
</button>
</div>
</div>
<!-- Game Configuration --> <!-- UNDER/OVER Configuration -->
<div class="card mb-4"> <div class="row mb-4">
<div class="card-header"> <div class="col-12">
<h5><i class="fas fa-cogs me-2"></i>Game Configuration</h5> <div class="card">
</div> <div class="card-header">
<div class="card-body"> <h5 class="card-title mb-0">
<div class="row"> <i class="fas fa-clock me-2"></i>UNDER/OVER Time Configuration
<div class="col-md-6"> </h5>
<label for="time-limit-input" class="form-label">Time Limit Between UNDER and OVER (seconds)</label> </div>
<div class="input-group"> <div class="card-body">
<input type="number" class="form-control" id="time-limit-input" min="1" max="300" value="90"> <div class="row">
<button class="btn btn-outline-primary" type="button" id="update-time-limit-btn"> <div class="col-md-6">
<i class="fas fa-save me-1"></i>Update <div class="input-group">
</button> <span class="input-group-text">
</div> <i class="fas fa-stopwatch me-2"></i>Time Limit
<small class="form-text text-muted">Default: 90 seconds. Range: 1-300 seconds.</small> </span>
</div> <input type="number" class="form-control" id="under-over-time-limit"
<div class="col-md-6"> placeholder="90" min="1" max="300" step="1" value="90">
<label for="cap-input" class="form-label">CAP of Redistributed Bets (%)</label> <span class="input-group-text">seconds</span>
<div class="input-group"> <button class="btn btn-outline-primary" id="btn-save-time-limit">
<input type="number" class="form-control" id="cap-input" min="20" max="90" step="1" value="70"> <i class="fas fa-save me-1"></i>Save
<button class="btn btn-outline-primary" type="button" id="update-cap-btn"> </button>
<i class="fas fa-save me-1"></i>Update
</button>
</div>
<small class="form-text text-muted">Default: 70%. Range: 20-90%.</small>
</div> </div>
<small class="text-muted mt-1 d-block">
Time limit between UNDER and OVER outcomes (1-300 seconds)
</small>
</div> </div>
<div class="row mt-3"> <div class="col-md-6">
<div class="col-12"> <div class="alert alert-info">
<div class="alert alert-info"> <i class="fas fa-info-circle me-2"></i>
<i class="fas fa-info-circle me-2"></i> <strong>UNDER/OVER Outcomes:</strong> These outcomes appear above the main extraction area
<strong>UNDER/OVER Outcomes:</strong> These outcomes appear separately above the main extraction area and are not included in the drag-and-drop associations below. and have a configurable time limit between them.
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div>
</div>
</div>
<!-- UNDER/OVER Outcomes Display --> <!-- Extraction Results Layout -->
<div class="card mb-4"> <div class="row">
<div class="card-header"> <!-- Available Outcomes Pool -->
<h5><i class="fas fa-layer-group me-2"></i>UNDER/OVER Outcomes</h5> <div class="col-md-4">
</div> <div class="card">
<div class="card-body"> <div class="card-header">
<div class="row"> <h5 class="card-title mb-0">
<div class="col-md-6"> <i class="fas fa-list me-2"></i>Available Outcomes
<div class="outcome-item under-over-item" data-outcome="UNDER"> </h5>
<span class="badge bg-warning fs-6 p-2">UNDER</span> </div>
</div> <div class="card-body">
</div> <div id="outcomes-pool" class="extraction-pool">
<div class="col-md-6"> <!-- Outcomes will be loaded here -->
<div class="outcome-item under-over-item" data-outcome="OVER"> <div class="text-center text-muted">
<span class="badge bg-warning fs-6 p-2">OVER</span> <i class="fas fa-spinner fa-spin me-2"></i>Loading outcomes...
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
</div>
</div>
<!-- Extraction Layout --> <!-- Extraction Result Columns -->
<div class="row"> <div class="col-md-8">
<!-- Available Outcomes Pool --> <div class="row">
<div class="col-md-4"> <!-- WIN1 Column -->
<div class="card h-100"> <div class="col-md-4 mb-3">
<div class="card-header d-flex justify-content-between align-items-center"> <div class="card">
<h5><i class="fas fa-list me-2"></i>Available Outcomes</h5> <div class="card-header bg-primary text-white">
<span class="badge bg-secondary" id="available-count">0</span> <h6 class="card-title mb-0">
</div> <i class="fas fa-trophy me-2"></i>WIN1
<div class="card-body"> </h6>
<div id="available-outcomes" class="outcomes-container"> </div>
<!-- Available outcomes will be loaded here --> <div class="card-body">
<div id="win1-column" class="extraction-column" data-result="WIN1">
<div class="text-center text-muted">
<small>Drop outcomes here</small>
</div> </div>
<small class="text-muted">Outcomes not associated with all 3 results</small>
</div> </div>
</div> </div>
</div> </div>
</div>
<!-- Extraction Result Columns --> <!-- X Column -->
<div class="col-md-8"> <div class="col-md-4 mb-3">
<div class="row"> <div class="card">
<!-- WIN1 Column --> <div class="card-header bg-secondary text-white">
<div class="col-md-4 mb-3"> <h6 class="card-title mb-0">
<div class="card h-100"> <i class="fas fa-equals me-2"></i>X
<div class="card-header text-center bg-primary text-white"> </h6>
<h5 class="mb-0">
<i class="fas fa-trophy me-2"></i>WIN1
</h5>
</div>
<div class="card-body">
<div id="win1-column" class="extraction-column outcomes-container" data-result="WIN1">
<!-- WIN1 associated outcomes will be loaded here -->
</div>
</div>
</div>
</div>
<!-- X Column -->
<div class="col-md-4 mb-3">
<div class="card h-100">
<div class="card-header text-center bg-info text-white">
<h5 class="mb-0">
<i class="fas fa-balance-scale me-2"></i>X
</h5>
</div>
<div class="card-body">
<div id="x-column" class="extraction-column outcomes-container" data-result="X">
<!-- X associated outcomes will be loaded here -->
</div>
</div>
</div>
</div>
<!-- WIN2 Column -->
<div class="col-md-4 mb-3">
<div class="card h-100">
<div class="card-header text-center bg-success text-white">
<h5 class="mb-0">
<i class="fas fa-medal me-2"></i>WIN2
</h5>
</div>
<div class="card-body">
<div id="win2-column" class="extraction-column outcomes-container" data-result="WIN2">
<!-- WIN2 associated outcomes will be loaded here -->
</div>
</div>
</div>
</div>
</div> </div>
<div class="card-body">
<!-- Trash Bin --> <div id="x-column" class="extraction-column" data-result="X">
<div class="row mt-3"> <div class="text-center text-muted">
<div class="col-12"> <small>Drop outcomes here</small>
<div class="card">
<div class="card-body text-center">
<div id="trash-bin" class="trash-bin outcomes-container">
<i class="fas fa-trash-alt fa-2x text-danger"></i>
<p class="mb-0 mt-2">Drop outcomes here to remove associations</p>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- Statistics --> <!-- WIN2 Column -->
<div class="row mt-4"> <div class="col-md-4 mb-3">
<div class="col-md-3"> <div class="card">
<div class="card text-center"> <div class="card-header bg-success text-white">
<div class="card-body"> <h6 class="card-title mb-0">
<h5 class="card-title text-primary"> <i class="fas fa-trophy me-2"></i>WIN2
<i class="fas fa-list me-2"></i>Total Outcomes </h6>
</h5>
<h3 id="total-outcomes" class="text-primary">0</h3>
</div>
</div> </div>
</div> <div class="card-body">
<div class="col-md-3"> <div id="win2-column" class="extraction-column" data-result="WIN2">
<div class="card text-center"> <div class="text-center text-muted">
<div class="card-body"> <small>Drop outcomes here</small>
<h5 class="card-title text-info"> </div>
<i class="fas fa-link me-2"></i>Associated
</h5>
<h3 id="associated-outcomes" class="text-info">0</h3>
</div> </div>
</div> </div>
</div> </div>
<div class="col-md-3"> </div>
<div class="card text-center"> </div>
<div class="card-body">
<h5 class="card-title text-warning"> <!-- Trash Bin -->
<i class="fas fa-clock me-2"></i>Available <div class="row">
</h5> <div class="col-12">
<h3 id="available-outcomes-count" class="text-warning">0</h3> <div class="card">
<div class="card-body text-center">
<div id="trash-bin" class="trash-bin">
<i class="fas fa-trash-alt fa-2x text-danger"></i>
<div class="mt-2">
<strong>Drop to Remove Association</strong>
</div>
</div> </div>
</div> </div>
</div> </div>
<div class="col-md-3"> </div>
<div class="card text-center"> </div>
<div class="card-body"> </div>
<h5 class="card-title text-success"> </div>
<i class="fas fa-save me-2"></i>Last Saved
</h5> <!-- Current Associations Display -->
<h6 id="last-saved" class="text-success">--</h6> <div class="row mt-4">
</div> <div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">
<i class="fas fa-link me-2"></i>Current Associations
</h5>
</div>
<div class="card-body">
<div id="associations-display">
<div class="text-center text-muted">
<i class="fas fa-spinner fa-spin me-2"></i>Loading associations...
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
{% endblock %}
{% block scripts %}
<style> <style>
.outcomes-container { .extraction-pool, .extraction-column {
min-height: 200px; min-height: 200px;
border: 2px dashed #dee2e6; border: 2px dashed #dee2e6;
border-radius: 0.375rem; border-radius: 0.375rem;
padding: 1rem; padding: 1rem;
background-color: #f8f9fa; background-color: #f8f9fa;
transition: all 0.3s ease;
}
.outcomes-container:hover {
border-color: #adb5bd;
background-color: #e9ecef;
} }
.outcome-item { .extraction-outcome {
display: inline-block; display: inline-block;
background-color: #ffffff;
border: 1px solid #dee2e6;
border-radius: 0.25rem;
padding: 0.5rem 0.75rem;
margin: 0.25rem; margin: 0.25rem;
cursor: move; cursor: move;
user-select: none; user-select: none;
transition: all 0.2s ease; transition: all 0.2s ease;
} }
.outcome-item:hover { .extraction-outcome:hover {
transform: translateY(-2px); background-color: #e9ecef;
box-shadow: 0 4px 8px rgba(0,0,0,0.1); border-color: #adb5bd;
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
} }
.outcome-item.dragging { .extraction-outcome.dragging {
opacity: 0.5; opacity: 0.5;
transform: rotate(5deg); transform: rotate(5deg);
} }
.extraction-column {
min-height: 300px;
}
.extraction-column .outcome-item {
display: block;
margin-bottom: 0.5rem;
}
.trash-bin { .trash-bin {
border: 2px dashed #dc3545; border: 2px dashed #dc3545;
border-radius: 0.5rem; border-radius: 0.375rem;
padding: 2rem; padding: 2rem;
background-color: #f8d7da; background-color: #f8d7da;
transition: all 0.3s ease; transition: all 0.2s ease;
} }
.trash-bin:hover { .trash-bin:hover {
...@@ -266,431 +214,393 @@ ...@@ -266,431 +214,393 @@
border-color: #c82333; border-color: #c82333;
} }
.trash-bin.drag-over { .extraction-column.drop-target {
background-color: #dc3545; border-color: #007bff;
border-color: #bd2130; background-color: #e7f3ff;
color: white;
} }
.under-over-item { .trash-bin.drop-target {
text-align: center; border-color: #dc3545;
pointer-events: none; background-color: #f5c6cb;
}
.under-over-item .badge {
font-size: 1.1em;
padding: 0.5rem 1rem;
} }
</style> </style>
<script> <script>
let allOutcomes = [];
let associations = {};
let draggedElement = null;
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
loadData(); let draggedElement = null;
setupEventListeners(); let draggedOutcome = null;
setupDragAndDrop();
});
function loadData() { // Load data on page load
// Load outcomes and associations loadAvailableOutcomes();
Promise.all([ loadCurrentAssociations();
fetch('/api/extraction/outcomes').then(r => r.json()), loadTimeLimitConfig();
fetch('/api/extraction/associations').then(r => r.json()),
fetch('/api/extraction/config').then(r => r.json())
]).then(([outcomesData, associationsData, configData]) => {
if (outcomesData.success) {
allOutcomes = outcomesData.outcomes;
}
if (associationsData.success) { // Time limit save button
associations = {}; document.getElementById('btn-save-time-limit').addEventListener('click', function() {
associationsData.associations.forEach(assoc => { saveTimeLimitConfig();
if (!associations[assoc.extraction_result]) {
associations[assoc.extraction_result] = [];
}
associations[assoc.extraction_result].push(assoc.outcome_name);
});
}
if (configData.success && configData.config) {
document.getElementById('time-limit-input').value = configData.config.under_over_time_limit || 90;
document.getElementById('cap-input').value = configData.config.redistributed_bets_cap || 70;
}
renderOutcomes();
updateStatistics();
}).catch(error => {
console.error('Error loading data:', error);
alert('Failed to load extraction data');
}); });
}
function setupEventListeners() {
// Save associations button
document.getElementById('save-associations-btn').addEventListener('click', saveAssociations);
// Update time limit button // Drag and drop functionality
document.getElementById('update-time-limit-btn').addEventListener('click', updateTimeLimit); setupDragAndDrop();
// Update CAP button function setupDragAndDrop() {
document.getElementById('update-cap-btn').addEventListener('click', updateCap); // Make outcomes draggable
} document.addEventListener('dragstart', function(e) {
if (e.target.classList.contains('extraction-outcome')) {
draggedElement = e.target;
draggedOutcome = e.target.dataset.outcome;
e.target.classList.add('dragging');
}
});
function setupDragAndDrop() { document.addEventListener('dragend', function(e) {
// Make outcome items draggable if (e.target.classList.contains('extraction-outcome')) {
document.addEventListener('dragstart', handleDragStart); e.target.classList.remove('dragging');
document.addEventListener('dragend', handleDragEnd); draggedElement = null;
draggedOutcome = null;
// Setup drop zones }
const dropZones = document.querySelectorAll('.outcomes-container'); });
dropZones.forEach(zone => {
zone.addEventListener('dragover', handleDragOver);
zone.addEventListener('dragleave', handleDragLeave);
zone.addEventListener('drop', handleDrop);
});
}
function handleDragStart(e) { // Setup drop zones
if (e.target.classList.contains('outcome-item')) { document.addEventListener('dragover', function(e) {
draggedElement = e.target; if (e.target.classList.contains('extraction-column') ||
e.target.classList.add('dragging'); e.target.classList.contains('trash-bin') ||
e.dataTransfer.effectAllowed = 'move'; e.target.closest('.extraction-column') ||
e.dataTransfer.setData('text/html', e.target.outerHTML); e.target.closest('.trash-bin')) {
e.dataTransfer.setData('text/plain', e.target.dataset.outcome); e.preventDefault();
} e.target.classList.add('drop-target');
} }
});
function handleDragEnd(e) { document.addEventListener('dragleave', function(e) {
if (draggedElement) { if (e.target.classList.contains('extraction-column') ||
draggedElement.classList.remove('dragging'); e.target.classList.contains('trash-bin')) {
draggedElement = null; e.target.classList.remove('drop-target');
} }
});
// Remove drag-over class from all zones document.addEventListener('drop', function(e) {
document.querySelectorAll('.outcomes-container').forEach(zone => { if (e.target.classList.contains('extraction-column') ||
zone.classList.remove('drag-over'); e.target.closest('.extraction-column') ||
}); e.target.classList.contains('trash-bin') ||
} e.target.closest('.trash-bin')) {
e.preventDefault();
// Remove drop target styling
document.querySelectorAll('.drop-target').forEach(el => {
el.classList.remove('drop-target');
});
if (!draggedOutcome) return;
// Determine target
let target = e.target;
if (e.target.closest('.extraction-column')) {
target = e.target.closest('.extraction-column');
} else if (e.target.closest('.trash-bin')) {
target = e.target.closest('.trash-bin');
}
function handleDragOver(e) { if (target.classList.contains('extraction-column')) {
e.preventDefault(); // Move to extraction result column
e.dataTransfer.dropEffect = 'move'; const extractionResult = target.dataset.result;
moveOutcomeToResult(draggedOutcome, extractionResult);
} else if (target.classList.contains('trash-bin')) {
// Remove association
removeOutcomeAssociation(draggedOutcome);
}
}
});
}
if (e.target.classList.contains('outcomes-container') || e.target.closest('.outcomes-container')) { function loadAvailableOutcomes() {
const container = e.target.closest('.outcomes-container'); fetch('/api/extraction/outcomes')
container.classList.add('drag-over'); .then(response => response.json())
.then(data => {
if (data.success) {
const pool = document.getElementById('outcomes-pool');
pool.innerHTML = '';
// Separate UNDER/OVER from other outcomes
const underOverOutcomes = [];
const regularOutcomes = [];
data.outcomes.forEach(outcome => {
if (outcome === 'UNDER' || outcome === 'OVER') {
underOverOutcomes.push(outcome);
} else {
regularOutcomes.push(outcome);
}
});
// Display UNDER/OVER separately
if (underOverOutcomes.length > 0) {
const underOverSection = document.createElement('div');
underOverSection.className = 'mb-3';
underOverSection.innerHTML = `
<h6 class="text-muted mb-2">
<i class="fas fa-clock me-1"></i>UNDER/OVER Outcomes
</h6>
<div class="alert alert-warning">
<small>These outcomes appear above the main extraction area with time limits.</small>
</div>
`;
pool.appendChild(underOverSection);
}
// Special handling for trash bin // Display regular outcomes
if (container.id === 'trash-bin') { if (regularOutcomes.length > 0) {
container.classList.add('drag-over'); const regularSection = document.createElement('div');
} regularSection.innerHTML = `
<h6 class="text-muted mb-2">
<i class="fas fa-list me-1"></i>Regular Outcomes
</h6>
`;
regularOutcomes.forEach(outcome => {
const outcomeElement = document.createElement('div');
outcomeElement.className = 'extraction-outcome';
outcomeElement.draggable = true;
outcomeElement.dataset.outcome = outcome;
outcomeElement.textContent = outcome;
regularSection.appendChild(outcomeElement);
});
pool.appendChild(regularSection);
}
} else {
document.getElementById('outcomes-pool').innerHTML =
'<div class="alert alert-danger">Failed to load outcomes</div>';
}
})
.catch(error => {
console.error('Error loading outcomes:', error);
document.getElementById('outcomes-pool').innerHTML =
'<div class="alert alert-danger">Error loading outcomes</div>';
});
} }
}
function handleDragLeave(e) { function loadCurrentAssociations() {
if (e.target.classList.contains('outcomes-container') || e.target.closest('.outcomes-container')) { fetch('/api/extraction/associations')
const container = e.target.closest('.outcomes-container'); .then(response => response.json())
container.classList.remove('drag-over'); .then(data => {
if (data.success) {
// Clear existing associations from columns
document.querySelectorAll('.extraction-column').forEach(column => {
const placeholder = column.querySelector('.text-center');
if (placeholder) {
column.innerHTML = '';
column.appendChild(placeholder);
} else {
column.innerHTML = '<div class="text-center text-muted"><small>Drop outcomes here</small></div>';
}
});
// Add outcomes to their respective columns
data.associations.forEach(assoc => {
addOutcomeToColumn(assoc.outcome_name, assoc.extraction_result);
});
// Update associations display
updateAssociationsDisplay(data.associations);
} else {
document.getElementById('associations-display').innerHTML =
'<div class="alert alert-danger">Failed to load associations</div>';
}
})
.catch(error => {
console.error('Error loading associations:', error);
document.getElementById('associations-display').innerHTML =
'<div class="alert alert-danger">Error loading associations</div>';
});
} }
}
function handleDrop(e) { function addOutcomeToColumn(outcomeName, extractionResult) {
e.preventDefault(); const column = document.querySelector(`[data-result="${extractionResult}"]`);
if (!column) return;
if (!draggedElement) return; // Remove placeholder if present
const placeholder = column.querySelector('.text-center');
const dropZone = e.target.closest('.outcomes-container'); if (placeholder) {
if (!dropZone) return; placeholder.remove();
const outcomeName = draggedElement.dataset.outcome;
const sourceContainer = draggedElement.closest('.outcomes-container');
// Determine source and destination types
const isFromResultColumn = sourceContainer && sourceContainer.classList.contains('extraction-column');
const isFromAvailable = sourceContainer && sourceContainer.id === 'available-outcomes';
const isToResultColumn = dropZone.classList.contains('extraction-column');
const isToAvailable = dropZone.id === 'available-outcomes';
const isToTrash = dropZone.id === 'trash-bin';
if (isToTrash) {
if (isFromResultColumn) {
// Remove from specific result when dragged from result column to trash
const sourceResult = sourceContainer.dataset.result;
removeAssociation(outcomeName, sourceResult);
} else {
// Remove from all associations when dragged from available to trash
removeAssociation(outcomeName);
} }
renderOutcomes();
} else if (isToAvailable) {
// Remove all associations when dragged to available outcomes
removeAssociation(outcomeName);
renderOutcomes();
} else if (isToResultColumn) {
const destinationResult = dropZone.dataset.result;
// Always add the association - allow multiple associations
addAssociation(outcomeName, destinationResult);
renderOutcomes();
}
dropZone.classList.remove('drag-over');
updateStatistics();
// Auto-save associations after each drop (no alerts for drag & drop) // Add outcome element
saveAssociations(false); const outcomeElement = document.createElement('div');
} outcomeElement.className = 'extraction-outcome';
outcomeElement.draggable = true;
function addAssociation(outcomeName, extractionResult) { outcomeElement.dataset.outcome = outcomeName;
// Add to association (allow multiple associations) outcomeElement.textContent = outcomeName;
if (!associations[extractionResult]) { column.appendChild(outcomeElement);
associations[extractionResult] = [];
} }
// Only add if not already associated with this result function moveOutcomeToResult(outcomeName, extractionResult) {
if (!associations[extractionResult].includes(outcomeName)) { // Save association to server
associations[extractionResult].push(outcomeName); fetch('/api/extraction/associations', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
associations: [{
outcome_name: outcomeName,
extraction_result: extractionResult
}]
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
// Reload associations to reflect changes
loadCurrentAssociations();
loadAvailableOutcomes(); // Refresh available outcomes
} else {
alert('Failed to save association: ' + (data.error || 'Unknown error'));
}
})
.catch(error => {
console.error('Error saving association:', error);
alert('Error saving association: ' + error.message);
});
} }
}
function removeAssociation(outcomeName, specificResult = null) { function removeOutcomeAssociation(outcomeName) {
if (specificResult) { // For now, we'll need to get all associations and remove the specific one
// Remove from specific result only // In a full implementation, you might want a specific delete endpoint
if (associations[specificResult]) { fetch('/api/extraction/associations')
associations[specificResult] = associations[specificResult].filter(outcome => outcome !== outcomeName); .then(response => response.json())
} .then(data => {
} else { if (data.success) {
// Remove from all associations // Filter out the association to remove
Object.keys(associations).forEach(result => { const updatedAssociations = data.associations.filter(
associations[result] = associations[result].filter(outcome => outcome !== outcomeName); assoc => assoc.outcome_name !== outcomeName
);
// Save updated associations
fetch('/api/extraction/associations', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
associations: updatedAssociations
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
loadCurrentAssociations();
loadAvailableOutcomes();
} else {
alert('Failed to remove association: ' + (data.error || 'Unknown error'));
}
});
}
})
.catch(error => {
console.error('Error removing association:', error);
alert('Error removing association: ' + error.message);
}); });
} }
}
function renderOutcomes() { function updateAssociationsDisplay(associations) {
const availableContainer = document.getElementById('available-outcomes'); const display = document.getElementById('associations-display');
const win1Container = document.getElementById('win1-column');
const xContainer = document.getElementById('x-column'); if (associations.length === 0) {
const win2Container = document.getElementById('win2-column'); display.innerHTML = '<div class="text-center text-muted">No associations configured</div>';
return;
// Clear containers
availableContainer.innerHTML = '';
win1Container.innerHTML = '';
xContainer.innerHTML = '';
win2Container.innerHTML = '';
// Separate UNDER/OVER from regular outcomes
const regularOutcomes = allOutcomes.filter(outcome => !['UNDER', 'OVER'].includes(outcome));
const underOverOutcomes = allOutcomes.filter(outcome => ['UNDER', 'OVER'].includes(outcome));
// Render regular outcomes
regularOutcomes.forEach(outcome => {
const outcomeElement = createOutcomeElement(outcome);
// Count how many results this outcome is associated with
const associatedResults = Object.keys(associations).filter(result =>
associations[result].includes(outcome)
);
// Always show outcome in available outcomes unless associated with ALL 3 results
if (associatedResults.length < 3) {
availableContainer.appendChild(outcomeElement.cloneNode(true));
} }
// Show outcome in each associated result column // Group associations by extraction result
associatedResults.forEach(result => { const grouped = {};
const container = getContainerForResult(result); associations.forEach(assoc => {
if (container) { if (!grouped[assoc.extraction_result]) {
container.appendChild(outcomeElement.cloneNode(true)); grouped[assoc.extraction_result] = [];
} }
grouped[assoc.extraction_result].push(assoc.outcome_name);
}); });
});
}
function createOutcomeElement(outcome) { let html = '<div class="row">';
const element = document.createElement('div'); Object.keys(grouped).forEach(result => {
element.className = 'outcome-item'; html += `
element.dataset.outcome = outcome; <div class="col-md-4 mb-3">
element.draggable = true; <div class="card">
<div class="card-header">
// All outcome labels should be red <strong>${result}</strong>
let badgeClass = 'bg-danger'; </div>
<div class="card-body">
<div class="d-flex flex-wrap">
${grouped[result].map(outcome =>
`<span class="badge bg-secondary me-1 mb-1">${outcome}</span>`
).join('')}
</div>
</div>
</div>
</div>
`;
});
html += '</div>';
element.innerHTML = `<span class="badge ${badgeClass}">${outcome}</span>`; display.innerHTML = html;
return element; }
}
function getContainerForResult(result) { function loadTimeLimitConfig() {
switch (result) { fetch('/api/extraction/config')
case 'WIN1': return document.getElementById('win1-column'); .then(response => response.json())
case 'X': return document.getElementById('x-column'); .then(data => {
case 'WIN2': return document.getElementById('win2-column'); if (data.success && data.config && data.config.under_over_time_limit) {
default: return null; document.getElementById('under-over-time-limit').value = data.config.under_over_time_limit;
}
})
.catch(error => {
console.error('Error loading time limit config:', error);
});
} }
}
function updateStatistics() { function saveTimeLimitConfig() {
const totalOutcomes = allOutcomes.filter(outcome => !['UNDER', 'OVER'].includes(outcome)).length; const timeLimitInput = document.getElementById('under-over-time-limit');
const associatedCount = Object.values(associations).reduce((sum, outcomes) => sum + outcomes.length, 0); const timeLimitValue = parseInt(timeLimitInput.value);
// Count outcomes that are NOT associated with all 3 results (these appear in available) if (!timeLimitValue || timeLimitValue < 1 || timeLimitValue > 300) {
const availableCount = allOutcomes.filter(outcome => { alert('Please enter a valid time limit between 1 and 300 seconds');
if (['UNDER', 'OVER'].includes(outcome)) return false; return;
const associatedResults = Object.keys(associations).filter(result => }
associations[result].includes(outcome)
);
return associatedResults.length < 3;
}).length;
document.getElementById('total-outcomes').textContent = totalOutcomes;
document.getElementById('associated-outcomes').textContent = associatedCount;
document.getElementById('available-outcomes-count').textContent = availableCount;
document.getElementById('available-count').textContent = availableCount;
}
function saveAssociations(showAlerts = true) { const saveBtn = document.getElementById('btn-save-time-limit');
const saveBtn = document.getElementById('save-associations-btn'); const originalText = saveBtn.innerHTML;
const originalText = saveBtn.innerHTML;
if (showAlerts) { // Show loading state
saveBtn.disabled = true; saveBtn.disabled = true;
saveBtn.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>Saving...'; saveBtn.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>Saving...';
}
// Convert associations to API format fetch('/api/extraction/config', {
const associationsData = []; method: 'POST',
Object.keys(associations).forEach(result => { headers: {
associations[result].forEach(outcome => { 'Content-Type': 'application/json',
associationsData.push({ },
outcome_name: outcome, body: JSON.stringify({
extraction_result: result config_key: 'under_over_time_limit',
}); config_value: timeLimitValue,
}); value_type: 'int'
}); })
})
fetch('/api/extraction/associations', { .then(response => response.json())
method: 'POST', .then(data => {
headers: { if (data.success) {
'Content-Type': 'application/json', alert('Time limit saved successfully!');
}, } else {
body: JSON.stringify({ associations: associationsData }) alert('Failed to save time limit: ' + (data.error || 'Unknown error'));
})
.then(response => response.json())
.then(data => {
if (data.success) {
document.getElementById('last-saved').textContent = new Date().toLocaleTimeString();
if (showAlerts) {
alert('Associations saved successfully!');
}
} else {
if (showAlerts) {
alert('Error saving associations: ' + (data.error || 'Unknown error'));
} }
} })
}) .catch(error => {
.catch(error => { alert('Error saving time limit: ' + error.message);
console.error('Error:', error); })
if (showAlerts) { .finally(() => {
alert('Failed to save associations: ' + error.message); // Restore button state
}
})
.finally(() => {
if (showAlerts) {
saveBtn.disabled = false; saveBtn.disabled = false;
saveBtn.innerHTML = originalText; saveBtn.innerHTML = originalText;
} });
});
}
function updateTimeLimit() {
const timeLimitInput = document.getElementById('time-limit-input');
const timeLimit = parseInt(timeLimitInput.value);
if (timeLimit < 1 || timeLimit > 300) {
alert('Time limit must be between 1 and 300 seconds');
return;
}
const updateBtn = document.getElementById('update-time-limit-btn');
const originalText = updateBtn.innerHTML;
updateBtn.disabled = true;
updateBtn.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>Updating...';
fetch('/api/extraction/config', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
config_key: 'under_over_time_limit',
config_value: timeLimit.toString(),
value_type: 'int'
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
alert('Time limit updated successfully!');
} else {
alert('Error updating time limit: ' + (data.error || 'Unknown error'));
}
})
.catch(error => {
console.error('Error:', error);
alert('Failed to update time limit: ' + error.message);
})
.finally(() => {
updateBtn.disabled = false;
updateBtn.innerHTML = originalText;
});
}
function updateCap() {
const capInput = document.getElementById('cap-input');
const cap = parseInt(capInput.value);
if (cap < 20 || cap > 90) {
alert('CAP must be between 20 and 90 percent');
return;
} }
});
const updateBtn = document.getElementById('update-cap-btn');
const originalText = updateBtn.innerHTML;
updateBtn.disabled = true;
updateBtn.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>Updating...';
fetch('/api/extraction/config', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
config_key: 'redistributed_bets_cap',
config_value: cap.toString(),
value_type: 'int'
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
alert('CAP updated successfully!');
} else {
alert('Error updating CAP: ' + (data.error || 'Unknown error'));
}
})
.catch(error => {
console.error('Error:', error);
alert('Failed to update CAP: ' + error.message);
})
.finally(() => {
updateBtn.disabled = false;
updateBtn.innerHTML = originalText;
});
}
</script> </script>
{% endblock %} {% endblock %}
\ No newline at end of file
...@@ -10,12 +10,15 @@ ...@@ -10,12 +10,15 @@
<nav aria-label="breadcrumb"> <nav aria-label="breadcrumb">
<ol class="breadcrumb"> <ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{{ url_for('main.fixtures') }}">Fixtures</a></li> <li class="breadcrumb-item"><a href="{{ url_for('main.fixtures') }}">Fixtures</a></li>
<li class="breadcrumb-item active" aria-current="page">Match Details</li> <li class="breadcrumb-item active" aria-current="page">Fixture Details</li>
</ol> </ol>
</nav> </nav>
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="d-flex justify-content-between align-items-center mb-4">
<h1><i class="fas fa-boxing me-2"></i>Fixture Details</h1> <div>
<h1><i class="fas fa-boxing me-2"></i>Fixture Details</h1>
<p class="mb-0 text-muted">All matches in this fixture</p>
</div>
<a href="{{ url_for('main.fixtures') }}" class="btn btn-outline-secondary"> <a href="{{ url_for('main.fixtures') }}" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left me-1"></i>Back to Fixtures <i class="fas fa-arrow-left me-1"></i>Back to Fixtures
</a> </a>
...@@ -37,66 +40,96 @@ ...@@ -37,66 +40,96 @@
<!-- Fixture Details Content --> <!-- Fixture Details Content -->
<div id="fixture-content" style="display: none;"> <div id="fixture-content" style="display: none;">
<!-- Match Information Card --> <!-- Fixture Information Card -->
<div class="row"> <div class="row">
<div class="col-lg-8"> <div class="col-12">
<div class="card mb-4"> <div class="card mb-4">
<div class="card-header"> <div class="card-header">
<h5><i class="fas fa-info-circle me-2"></i>Match Information</h5> <h5><i class="fas fa-info-circle me-2"></i>Fixture Information</h5>
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="row"> <div class="row">
<div class="col-md-6"> <div class="col-md-4">
<table class="table table-borderless"> <table class="table table-borderless">
<tr> <tr>
<td><strong>Match Number:</strong></td> <td><strong>Fixture ID:</strong></td>
<td><span class="badge bg-primary fs-6" id="match-number"></span></td> <td><span class="badge bg-primary fs-6" id="fixture-id"></span></td>
</tr>
<tr>
<td><strong>Fighter 1:</strong></td>
<td><span class="fw-bold text-primary" id="fighter1"></span></td>
</tr>
<tr>
<td><strong>Fighter 2:</strong></td>
<td><span class="fw-bold text-primary" id="fighter2"></span></td>
</tr> </tr>
<tr> <tr>
<td><strong>Venue:</strong></td> <td><strong>Status:</strong></td>
<td><span id="venue"></span></td> <td><span id="fixture-status-badge"></span></td>
</tr> </tr>
<tr> <tr>
<td><strong>Fixture ID:</strong></td> <td><strong>Match Count:</strong></td>
<td><small class="text-muted" id="fixture-id"></small></td> <td><span class="fw-bold" id="match-count"></span></td>
</tr> </tr>
</table> </table>
</div> </div>
<div class="col-md-6"> <div class="col-md-4">
<table class="table table-borderless"> <table class="table table-borderless">
<tr> <tr>
<td><strong>Status:</strong></td> <td><strong>Venue:</strong></td>
<td><span id="status-badge"></span></td> <td><span id="venue"></span></td>
</tr>
<tr>
<td><strong>Active:</strong></td>
<td><span id="active-status"></span></td>
</tr> </tr>
<tr> <tr>
<td><strong>Start Time:</strong></td> <td><strong>Start Time:</strong></td>
<td><span id="start-time" class="text-muted">Not set</span></td> <td><span id="start-time" class="text-muted">Not set</span></td>
</tr> </tr>
<tr> <tr>
<td><strong>End Time:</strong></td> <td><strong>Created:</strong></td>
<td><span id="end-time" class="text-muted">Not set</span></td> <td><small class="text-muted" id="created-at"></small></td>
</tr>
</table>
</div>
<div class="col-md-4">
<table class="table table-borderless">
<tr>
<td><strong>Sample Fighters:</strong></td>
<td><small class="text-muted" id="sample-fighters"></small></td>
</tr> </tr>
<tr> <tr>
<td><strong>Result:</strong></td> <td><strong>Active Time:</strong></td>
<td><span id="result" class="text-muted">Not available</span></td> <td><small class="text-muted" id="active-time"></small></td>
</tr> </tr>
</table> </table>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div>
</div>
<!-- Matches in Fixture -->
<div class="row">
<div class="col-12">
<div class="card mb-4">
<div class="card-header d-flex justify-content-between align-items-center">
<h5><i class="fas fa-list me-2"></i>Matches in This Fixture</h5>
<span id="matches-count" class="badge bg-secondary">0 matches</span>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>Match #</th>
<th>Fighters</th>
<th>Status</th>
<th>Start Time</th>
<th>Result</th>
<th>Outcomes</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="matches-tbody">
<!-- Matches will be loaded here -->
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<!-- Match Outcomes Card --> <!-- Match Outcomes Card -->
<div class="card mb-4"> <div class="card mb-4">
...@@ -133,38 +166,32 @@ ...@@ -133,38 +166,32 @@
<!-- Side Panel --> <!-- Side Panel -->
<div class="col-lg-4"> <div class="col-lg-4">
<!-- Upload Status Card --> <!-- Fixture Summary Card -->
<div class="card mb-4"> <div class="card mb-4">
<div class="card-header"> <div class="card-header">
<h5><i class="fas fa-cloud-upload-alt me-2"></i>Upload Status</h5> <h5><i class="fas fa-chart-bar me-2"></i>Fixture Summary</h5>
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="mb-3"> <div class="mb-3">
<label class="form-label">ZIP Upload Status</label> <label class="form-label">Status Distribution</label>
<div id="upload-status-badge"></div> <div id="status-summary">
</div> <!-- Status summary will be populated by JavaScript -->
<div class="mb-3" id="progress-container" style="display: none;">
<label class="form-label">Upload Progress</label>
<div class="progress">
<div class="progress-bar" role="progressbar" id="progress-bar" style="width: 0%">0%</div>
</div> </div>
</div> </div>
<div class="mb-3" id="zip-file-info" style="display: none;"> <div class="mb-3">
<label class="form-label">ZIP File</label> <label class="form-label">Upload Status</label>
<div class="small text-muted"> <div id="upload-summary">
<div><strong>Filename:</strong> <span id="zip-filename"></span></div> <!-- Upload summary will be populated by JavaScript -->
<div><strong>SHA1:</strong> <span id="zip-sha1sum" class="font-monospace"></span></div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- File Information Card --> <!-- File Information Card -->
<div class="card mb-4"> <div class="card mb-4">
<div class="card-header"> <div class="card-header">
<h5><i class="fas fa-file me-2"></i>File Information</h5> <h5><i class="fas fa-file me-2"></i>Source File</h5>
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="mb-3"> <div class="mb-3">
...@@ -176,20 +203,20 @@ ...@@ -176,20 +203,20 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Timestamps Card --> <!-- Timestamps Card -->
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<h5><i class="fas fa-clock me-2"></i>Timestamps</h5> <h5><i class="fas fa-clock me-2"></i>Fixture Timestamps</h5>
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="mb-2"> <div class="mb-2">
<label class="form-label small">Created</label> <label class="form-label small">Created</label>
<div class="text-muted small" id="created-at"></div> <div class="text-muted small" id="fixture-created-at"></div>
</div> </div>
<div> <div>
<label class="form-label small">Last Updated</label> <label class="form-label small">Last Updated</label>
<div class="text-muted small" id="updated-at"></div> <div class="text-muted small" id="fixture-updated-at"></div>
</div> </div>
</div> </div>
</div> </div>
...@@ -202,44 +229,72 @@ ...@@ -202,44 +229,72 @@
<script id="fixture-config" type="application/json"> <script id="fixture-config" type="application/json">
{ {
"matchId": {{ match_id | tojson }} "fixtureId": {{ fixture_id | tojson }}
} }
</script> </script>
<script> <script>
const config = JSON.parse(document.getElementById('fixture-config').textContent); const config = JSON.parse(document.getElementById('fixture-config').textContent);
const matchId = config.matchId; const fixtureId = config.fixtureId;
let cachedFixtureData = null;
let isInitialLoad = true;
// Load fixture details on page load // Load fixture details on page load
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
loadFixtureDetails(); loadFixtureDetails();
// Set up auto-refresh every 5 seconds
setInterval(loadFixtureDetails, 5000);
}); });
function loadFixtureDetails() { function loadFixtureDetails() {
const loading = document.getElementById('loading'); const loading = document.getElementById('loading');
const errorMessage = document.getElementById('error-message'); const errorMessage = document.getElementById('error-message');
const content = document.getElementById('fixture-content'); const content = document.getElementById('fixture-content');
loading.style.display = 'block'; // Only show loading state on initial load
errorMessage.style.display = 'none'; if (isInitialLoad) {
content.style.display = 'none'; loading.style.display = 'block';
errorMessage.style.display = 'none';
fetch(`/api/fixtures/${matchId}`) content.style.display = 'none';
}
fetch(`/api/fixtures/${fixtureId}`)
.then(response => response.json()) .then(response => response.json())
.then(data => { .then(data => {
if (data.success) { if (data.success) {
renderFixtureDetails(data.match); // Check if data has actually changed
content.style.display = 'block'; const currentDataString = JSON.stringify(data);
if (cachedFixtureData === currentDataString && !isInitialLoad) {
console.log('📦 No changes in fixture data, skipping update');
return;
}
cachedFixtureData = currentDataString;
if (isInitialLoad) {
renderFixtureDetails(data.fixture, data.matches);
content.style.display = 'block';
} else {
updateFixtureDetails(data.fixture, data.matches);
}
} else { } else {
showError(data.error || 'Failed to load fixture details'); if (isInitialLoad) {
showError(data.error || 'Failed to load fixture details');
}
} }
}) })
.catch(error => { .catch(error => {
console.error('Error:', error); console.error('Error:', error);
showError('Network error: ' + error.message); if (isInitialLoad) {
showError('Network error: ' + error.message);
}
}) })
.finally(() => { .finally(() => {
loading.style.display = 'none'; if (isInitialLoad) {
loading.style.display = 'none';
isInitialLoad = false;
}
}); });
} }
...@@ -248,125 +303,282 @@ function showError(message) { ...@@ -248,125 +303,282 @@ function showError(message) {
document.getElementById('error-message').style.display = 'block'; document.getElementById('error-message').style.display = 'block';
} }
function renderFixtureDetails(match) { function renderFixtureDetails(fixture, matches) {
// Basic match information // Basic fixture information
document.getElementById('match-number').textContent = '#' + match.match_number; document.getElementById('fixture-id').textContent = fixture.fixture_id;
document.getElementById('fighter1').textContent = match.fighter1_township; document.getElementById('fixture-status-badge').innerHTML = getFixtureStatusBadge(fixture);
document.getElementById('fighter2').textContent = match.fighter2_township; document.getElementById('match-count').textContent = fixture.match_count + ' matches';
document.getElementById('venue').textContent = match.venue_kampala_township; document.getElementById('venue').textContent = fixture.venue_kampala_township;
document.getElementById('fixture-id').textContent = match.fixture_id;
// Start time
// Status information if (fixture.start_time) {
document.getElementById('status-badge').innerHTML = getStatusBadge(match); document.getElementById('start-time').textContent = new Date(fixture.start_time).toLocaleString();
document.getElementById('active-status').innerHTML = match.active_status
? '<span class="badge bg-success">Active</span>'
: '<span class="badge bg-secondary">Inactive</span>';
// Times and result
if (match.start_time) {
document.getElementById('start-time').textContent = new Date(match.start_time).toLocaleString();
document.getElementById('start-time').classList.remove('text-muted'); document.getElementById('start-time').classList.remove('text-muted');
} }
if (match.end_time) { // Sample fighters (from first match)
document.getElementById('end-time').textContent = new Date(match.end_time).toLocaleString(); if (matches.length > 0) {
document.getElementById('end-time').classList.remove('text-muted'); const firstMatch = matches[0];
document.getElementById('sample-fighters').textContent = firstMatch.fighter1_township + ' vs ' + firstMatch.fighter2_township;
} }
if (match.result) { // Active time
document.getElementById('result').textContent = match.result; if (fixture.fixture_active_time) {
document.getElementById('result').classList.remove('text-muted'); document.getElementById('active-time').textContent = new Date(fixture.fixture_active_time * 1000).toLocaleString();
} }
// File information // File information (from first match)
document.getElementById('filename').textContent = match.filename; if (matches.length > 0) {
document.getElementById('file-sha1sum').textContent = match.file_sha1sum; const firstMatch = matches[0];
document.getElementById('filename').textContent = firstMatch.filename;
// Upload status document.getElementById('file-sha1sum').textContent = firstMatch.file_sha1sum;
renderUploadStatus(match); }
// Outcomes
renderOutcomes(match.outcomes || []);
// Timestamps // Timestamps
document.getElementById('created-at').textContent = new Date(match.created_at).toLocaleString(); document.getElementById('created-at').textContent = new Date(fixture.created_at).toLocaleString();
document.getElementById('updated-at').textContent = new Date(match.updated_at).toLocaleString(); document.getElementById('fixture-created-at').textContent = new Date(fixture.created_at).toLocaleString();
// Render matches table
renderMatchesTable(matches);
// Render fixture summary
renderFixtureSummary(matches);
} }
function renderUploadStatus(match) { function updateFixtureDetails(fixture, matches) {
const uploadBadge = document.getElementById('upload-status-badge'); // Update fixture information that might have changed
const progressContainer = document.getElementById('progress-container'); const statusBadge = document.getElementById('fixture-status-badge');
const zipFileInfo = document.getElementById('zip-file-info'); const newStatusBadge = getFixtureStatusBadge(fixture);
if (statusBadge.innerHTML !== newStatusBadge) {
uploadBadge.innerHTML = getUploadStatusBadge(match); statusBadge.innerHTML = newStatusBadge;
statusBadge.style.backgroundColor = '#fff3cd';
// Show progress bar if uploading setTimeout(() => {
if (match.zip_upload_status === 'uploading') { statusBadge.style.backgroundColor = '';
const progress = match.zip_upload_progress || 0; }, 1000);
const progressBar = document.getElementById('progress-bar');
progressBar.style.width = progress + '%';
progressBar.textContent = progress.toFixed(1) + '%';
progressContainer.style.display = 'block';
} }
// Show ZIP file info if available // Update match count
if (match.zip_filename) { const matchCountEl = document.getElementById('match-count');
document.getElementById('zip-filename').textContent = match.zip_filename; const newMatchCount = fixture.match_count + ' matches';
if (match.zip_sha1sum) { if (matchCountEl.textContent !== newMatchCount) {
document.getElementById('zip-sha1sum').textContent = match.zip_sha1sum; matchCountEl.textContent = newMatchCount;
}
zipFileInfo.style.display = 'block';
} }
}
function renderOutcomes(outcomes) { // Update start time if changed
const noOutcomes = document.getElementById('no-outcomes'); if (fixture.start_time) {
const outcomesContainer = document.getElementById('outcomes-table-container'); const startTimeEl = document.getElementById('start-time');
const outcomesCount = document.getElementById('outcomes-count'); const newStartTime = new Date(fixture.start_time).toLocaleString();
const tbody = document.getElementById('outcomes-tbody'); if (startTimeEl.textContent !== newStartTime) {
startTimeEl.textContent = newStartTime;
outcomesCount.textContent = outcomes.length + ' outcomes'; startTimeEl.classList.remove('text-muted');
}
if (outcomes.length === 0) {
noOutcomes.style.display = 'block';
outcomesContainer.style.display = 'none';
return;
} }
// Update matches table with smooth transitions
updateMatchesTable(matches);
noOutcomes.style.display = 'none'; // Update fixture summary
outcomesContainer.style.display = 'block'; renderFixtureSummary(matches);
}
function renderMatchesTable(matches) {
const tbody = document.getElementById('matches-tbody');
const matchesCount = document.getElementById('matches-count');
matchesCount.textContent = matches.length + ' matches';
tbody.innerHTML = ''; tbody.innerHTML = '';
outcomes.forEach(outcome => { matches.forEach(match => {
const row = document.createElement('tr'); const row = document.createElement('tr');
const startTimeDisplay = match.start_time ? new Date(match.start_time).toLocaleString() : 'Not set';
const resultDisplay = match.result || 'Not available';
const outcomesCount = match.outcome_count || 0;
row.innerHTML = ` row.innerHTML = `
<td><span class="badge bg-light text-dark">${outcome.column_name}</span></td> <td><strong>#${match.match_number}</strong></td>
<td><strong class="text-primary">${outcome.float_value}</strong></td> <td>
<td><small class="text-muted">${new Date(outcome.updated_at).toLocaleString()}</small></td> <div class="fw-bold">${match.fighter1_township}</div>
<small class="text-muted">vs</small>
<div class="fw-bold">${match.fighter2_township}</div>
</td>
<td>${getStatusBadge(match)}</td>
<td><small class="text-info">${startTimeDisplay}</small></td>
<td><small class="text-muted">${resultDisplay}</small></td>
<td><span class="badge bg-light text-dark">${outcomesCount} outcomes</span></td>
<td>
<a href="/matches/${match.id}/${fixtureId}" class="btn btn-sm btn-outline-primary">
<i class="fas fa-eye me-1"></i>View
</a>
</td>
`; `;
row.setAttribute('data-match-id', match.id);
tbody.appendChild(row); tbody.appendChild(row);
}); });
} }
function getStatusBadge(fixture) { function updateMatchesTable(matches) {
if (fixture.done) { const tbody = document.getElementById('matches-tbody');
return '<span class="badge bg-success"><i class="fas fa-check me-1"></i>Completed</span>'; const matchesCount = document.getElementById('matches-count');
} else if (fixture.running) {
return '<span class="badge bg-info"><i class="fas fa-play me-1"></i>Running</span>'; matchesCount.textContent = matches.length + ' matches';
} else {
return '<span class="badge bg-warning"><i class="fas fa-clock me-1"></i>Pending</span>'; // Get existing rows
const existingRows = Array.from(tbody.children);
const existingMatches = new Map();
existingRows.forEach(row => {
const matchId = row.getAttribute('data-match-id');
if (matchId) {
existingMatches.set(parseInt(matchId), row);
}
});
const processedMatches = new Set();
matches.forEach(match => {
processedMatches.add(match.id);
const startTimeDisplay = match.start_time ? new Date(match.start_time).toLocaleString() : 'Not set';
const resultDisplay = match.result || 'Not available';
const outcomesCount = match.outcome_count || 0;
const newRowHTML = `
<td><strong>#${match.match_number}</strong></td>
<td>
<div class="fw-bold">${match.fighter1_township}</div>
<small class="text-muted">vs</small>
<div class="fw-bold">${match.fighter2_township}</div>
</td>
<td>${getStatusBadge(match)}</td>
<td><small class="text-info">${startTimeDisplay}</small></td>
<td><small class="text-muted">${resultDisplay}</small></td>
<td><span class="badge bg-light text-dark">${outcomesCount} outcomes</span></td>
<td>
<a href="/matches/${match.id}/${fixtureId}" class="btn btn-sm btn-outline-primary">
<i class="fas fa-eye me-1"></i>View
</a>
</td>
`;
const existingRow = existingMatches.get(match.id);
if (existingRow) {
// Update existing row only if content changed
if (existingRow.innerHTML !== newRowHTML) {
existingRow.innerHTML = newRowHTML;
existingRow.style.backgroundColor = '#fff3cd'; // Highlight briefly
setTimeout(() => {
existingRow.style.backgroundColor = '';
}, 1000);
}
} else {
// Add new row
const row = document.createElement('tr');
row.setAttribute('data-match-id', match.id);
row.innerHTML = newRowHTML;
row.style.backgroundColor = '#d4edda'; // Highlight new row
tbody.appendChild(row);
setTimeout(() => {
row.style.backgroundColor = '';
}, 1000);
}
});
// Remove rows no longer in data
existingMatches.forEach((row, matchId) => {
if (!processedMatches.has(matchId)) {
row.style.backgroundColor = '#f8d7da'; // Highlight removed row
setTimeout(() => {
if (row.parentNode) {
row.parentNode.removeChild(row);
}
}, 500);
}
});
}
function renderFixtureSummary(matches) {
// Calculate status distribution
const statusCounts = {};
const uploadCounts = {};
matches.forEach(match => {
// Count statuses
const status = match.status || 'unknown';
statusCounts[status] = (statusCounts[status] || 0) + 1;
// Count upload statuses
const uploadStatus = match.zip_upload_status || 'pending';
uploadCounts[uploadStatus] = (uploadCounts[uploadStatus] || 0) + 1;
});
// Render status summary
const statusSummary = document.getElementById('status-summary');
statusSummary.innerHTML = Object.entries(statusCounts)
.map(([status, count]) => `<div class="small"><span class="badge bg-secondary">${status}</span> ${count}</div>`)
.join('');
// Render upload summary
const uploadSummary = document.getElementById('upload-summary');
uploadSummary.innerHTML = Object.entries(uploadCounts)
.map(([status, count]) => `<div class="small"><span class="badge bg-light text-dark">${status}</span> ${count}</div>`)
.join('');
}
function getFixtureStatusBadge(fixture) {
const status = fixture.fixture_status;
switch (status) {
case 'pending':
return '<span class="badge bg-warning"><i class="fas fa-clock me-1"></i>Pending</span>';
case 'running':
return '<span class="badge bg-info"><i class="fas fa-play me-1"></i>Running</span>';
case 'scheduled':
return '<span class="badge bg-secondary"><i class="fas fa-calendar me-1"></i>Scheduled</span>';
case 'bet':
return '<span class="badge bg-primary"><i class="fas fa-money-bill me-1"></i>Bet</span>';
case 'ingame':
return '<span class="badge bg-success"><i class="fas fa-gamepad me-1"></i>In Game</span>';
case 'end':
return '<span class="badge bg-dark"><i class="fas fa-stop me-1"></i>End</span>';
default:
return '<span class="badge bg-secondary"><i class="fas fa-question me-1"></i>Unknown</span>';
} }
} }
function getUploadStatusBadge(fixture) { function getStatusBadge(match) {
const status = fixture.zip_upload_status || 'pending'; const status = match.status;
const progress = fixture.zip_upload_progress || 0;
switch (status) {
case 'pending':
return '<span class="badge bg-warning"><i class="fas fa-clock me-1"></i>Pending</span>';
case 'scheduled':
return '<span class="badge bg-secondary"><i class="fas fa-calendar me-1"></i>Scheduled</span>';
case 'bet':
return '<span class="badge bg-primary"><i class="fas fa-money-bill me-1"></i>Bet</span>';
case 'ingame':
return '<span class="badge bg-success"><i class="fas fa-gamepad me-1"></i>In Game</span>';
case 'end':
return '<span class="badge bg-dark"><i class="fas fa-stop me-1"></i>End</span>';
case 'cancelled':
return '<span class="badge bg-danger"><i class="fas fa-times me-1"></i>Cancelled</span>';
case 'failed':
return '<span class="badge bg-danger"><i class="fas fa-exclamation-triangle me-1"></i>Failed</span>';
case 'paused':
return '<span class="badge bg-warning"><i class="fas fa-pause me-1"></i>Paused</span>';
default:
return '<span class="badge bg-secondary"><i class="fas fa-question me-1"></i>Unknown</span>';
}
}
function getUploadStatusBadge(match) {
const status = match.zip_upload_status || 'pending';
const progress = match.zip_upload_progress || 0;
switch (status) { switch (status) {
case 'completed': case 'completed':
return '<span class="badge bg-success"><i class="fas fa-check me-1"></i>Completed</span>'; return '<span class="badge bg-success"><i class="fas fa-check me-1"></i>Completed</span>';
case 'uploading': case 'uploading':
return `<span class="badge bg-info"><i class="fas fa-spinner fa-spin me-1"></i>Uploading (${progress.toFixed(1)}%)</span>`; return '<span class="badge bg-info"><i class="fas fa-spinner fa-spin me-1"></i>Uploading (' + progress.toFixed(1) + '%)</span>';
case 'failed': case 'failed':
return '<span class="badge bg-danger"><i class="fas fa-times me-1"></i>Failed</span>'; return '<span class="badge bg-danger"><i class="fas fa-times me-1"></i>Failed</span>';
default: default:
......
...@@ -8,9 +8,9 @@ ...@@ -8,9 +8,9 @@
<div class="col-12"> <div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-3"> <div class="d-flex justify-content-between align-items-center mb-3">
<div> <div>
<h1><i class="fas fa-list-ul me-2"></i>Fixtures</h1> <h1><i class="fas fa-list-ul me-2"></i>Fixtures</h1>
<p class="mb-0">View and manage synchronized boxing match fixtures.</p> <p class="mb-0">View and manage synchronized boxing match fixtures with status tracking.</p>
</div> </div>
{% if current_user.is_admin %} {% if current_user.is_admin %}
<div> <div>
<button type="button" class="btn btn-danger" id="reset-fixtures-btn"> <button type="button" class="btn btn-danger" id="reset-fixtures-btn">
...@@ -27,15 +27,6 @@ ...@@ -27,15 +27,6 @@
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="row"> <div class="row">
<div class="col-md-3">
<label for="status-filter" class="form-label">Status</label>
<select class="form-select" id="status-filter">
<option value="">All Status</option>
<option value="pending">Pending</option>
<option value="running">Running</option>
<option value="done">Completed</option>
</select>
</div>
<div class="col-md-3"> <div class="col-md-3">
<label for="upload-filter" class="form-label">Upload Status</label> <label for="upload-filter" class="form-label">Upload Status</label>
<select class="form-select" id="upload-filter"> <select class="form-select" id="upload-filter">
...@@ -46,6 +37,13 @@ ...@@ -46,6 +37,13 @@
<option value="failed">Failed</option> <option value="failed">Failed</option>
</select> </select>
</div> </div>
<div class="col-md-3">
<label for="past-filter" class="form-label">Past Fixtures</label>
<select class="form-select" id="past-filter">
<option value="hide">Hide Past</option>
<option value="show">Show Past</option>
</select>
</div>
<div class="col-md-4"> <div class="col-md-4">
<label for="search-input" class="form-label">Search Fighters</label> <label for="search-input" class="form-label">Search Fighters</label>
<input type="text" class="form-control" id="search-input" placeholder="Search by fighter names or venue"> <input type="text" class="form-control" id="search-input" placeholder="Search by fighter names or venue">
...@@ -122,11 +120,12 @@ ...@@ -122,11 +120,12 @@
<table class="table table-hover mb-0" id="fixtures-table"> <table class="table table-hover mb-0" id="fixtures-table">
<thead class="table-light"> <thead class="table-light">
<tr> <tr>
<th>Match #</th> <th>Fixture #</th>
<th>Fighters</th> <th>Fighters</th>
<th>Venue</th> <th>Venue</th>
<th>Status</th> <th>Status</th>
<th>Upload Status</th> <th>Upload Status</th>
<th>Start Time</th>
<th>Created</th> <th>Created</th>
<th>Actions</th> <th>Actions</th>
</tr> </tr>
...@@ -143,7 +142,7 @@ ...@@ -143,7 +142,7 @@
<div id="empty-state" class="text-center my-5" style="display: none;"> <div id="empty-state" class="text-center my-5" style="display: none;">
<i class="fas fa-inbox fa-4x text-muted mb-3"></i> <i class="fas fa-inbox fa-4x text-muted mb-3"></i>
<h4 class="text-muted">No Fixtures Found</h4> <h4 class="text-muted">No Fixtures Found</h4>
<p class="text-muted">No synchronized fixtures found. Check your API synchronization settings.</p> <p class="text-muted">No synchronized fixtures found. Check your API synchronization settings or upload fixture files.</p>
</div> </div>
</div> </div>
</div> </div>
...@@ -158,8 +157,8 @@ document.addEventListener('DOMContentLoaded', function() { ...@@ -158,8 +157,8 @@ document.addEventListener('DOMContentLoaded', function() {
// Event listeners // Event listeners
document.getElementById('refresh-btn').addEventListener('click', loadFixtures); document.getElementById('refresh-btn').addEventListener('click', loadFixtures);
document.getElementById('status-filter').addEventListener('change', filterFixtures);
document.getElementById('upload-filter').addEventListener('change', filterFixtures); document.getElementById('upload-filter').addEventListener('change', filterFixtures);
document.getElementById('past-filter').addEventListener('change', filterFixtures);
document.getElementById('search-input').addEventListener('input', filterFixtures); document.getElementById('search-input').addEventListener('input', filterFixtures);
// Reset fixtures button (admin only) // Reset fixtures button (admin only)
...@@ -167,13 +166,21 @@ document.addEventListener('DOMContentLoaded', function() { ...@@ -167,13 +166,21 @@ document.addEventListener('DOMContentLoaded', function() {
if (resetBtn) { if (resetBtn) {
resetBtn.addEventListener('click', resetFixtures); resetBtn.addEventListener('click', resetFixtures);
} }
// Auto-refresh fixtures every 5 seconds
setInterval(loadFixtures, 5000);
}); });
let isInitialLoad = true;
function loadFixtures() { function loadFixtures() {
const loading = document.getElementById('loading'); const loading = document.getElementById('loading');
const refreshBtn = document.getElementById('refresh-btn'); const refreshBtn = document.getElementById('refresh-btn');
loading.style.display = 'block'; // Only show loading spinner on initial load or manual refresh
if (isInitialLoad || refreshBtn.disabled) {
loading.style.display = 'block';
}
refreshBtn.disabled = true; refreshBtn.disabled = true;
fetch('/api/fixtures') fetch('/api/fixtures')
...@@ -184,65 +191,151 @@ function loadFixtures() { ...@@ -184,65 +191,151 @@ function loadFixtures() {
updateSummaryCards(); updateSummaryCards();
filterFixtures(); // This will also render the table filterFixtures(); // This will also render the table
} else { } else {
alert('Error loading fixtures: ' + (data.error || 'Unknown error')); if (isInitialLoad) {
alert('Error loading fixtures: ' + (data.error || 'Unknown error'));
}
} }
}) })
.catch(error => { .catch(error => {
console.error('Error:', error); console.error('Error:', error);
alert('Failed to load fixtures: ' + error.message); if (isInitialLoad) {
alert('Failed to load fixtures: ' + error.message);
}
}) })
.finally(() => { .finally(() => {
loading.style.display = 'none'; loading.style.display = 'none';
refreshBtn.disabled = false; refreshBtn.disabled = false;
isInitialLoad = false;
}); });
} }
function updateSummaryCards() { function updateSummaryCards() {
const totalCount = allFixtures.length; const totalCount = allFixtures.length;
const pendingCount = allFixtures.filter(f => !f.running && !f.done).length; const pendingCount = allFixtures.filter(f => f.fixture_status === 'pending').length;
const runningCount = allFixtures.filter(f => f.running && !f.done).length; const runningCount = allFixtures.filter(f => f.fixture_status === 'running').length;
const completedCount = allFixtures.filter(f => f.done).length; const scheduledCount = allFixtures.filter(f => f.fixture_status === 'scheduled').length;
const betCount = allFixtures.filter(f => f.fixture_status === 'bet').length;
const ingameCount = allFixtures.filter(f => f.fixture_status === 'ingame').length;
const endCount = allFixtures.filter(f => f.fixture_status === 'end').length;
document.getElementById('total-count').textContent = totalCount; document.getElementById('total-count').textContent = totalCount;
document.getElementById('pending-count').textContent = pendingCount; document.getElementById('pending-count').textContent = pendingCount;
document.getElementById('running-count').textContent = runningCount; document.getElementById('running-count').textContent = runningCount;
document.getElementById('completed-count').textContent = completedCount;
// Update the summary cards to show more status types
const summaryCards = document.getElementById('summary-cards');
const betInGameCount = betCount + ingameCount;
summaryCards.innerHTML = `
<div class="col-md-2">
<div class="card text-center">
<div class="card-body">
<h5 class="card-title text-primary">
<i class="fas fa-list me-2"></i>Total Fixtures
</h5>
<h3 id="total-count" class="text-primary">` + totalCount + `</h3>
</div>
</div>
</div>
<div class="col-md-2">
<div class="card text-center">
<div class="card-body">
<h5 class="card-title text-warning">
<i class="fas fa-clock me-2"></i>Pending
</h5>
<h3 id="pending-count" class="text-warning">` + pendingCount + `</h3>
</div>
</div>
</div>
<div class="col-md-2">
<div class="card text-center">
<div class="card-body">
<h5 class="card-title text-info">
<i class="fas fa-play me-2"></i>Running
</h5>
<h3 id="running-count" class="text-info">` + runningCount + `</h3>
</div>
</div>
</div>
<div class="col-md-2">
<div class="card text-center">
<div class="card-body">
<h5 class="card-title text-secondary">
<i class="fas fa-calendar me-2"></i>Scheduled
</h5>
<h3 class="text-secondary">` + scheduledCount + `</h3>
</div>
</div>
</div>
<div class="col-md-2">
<div class="card text-center">
<div class="card-body">
<h5 class="card-title text-success">
<i class="fas fa-gamepad me-2"></i>Bet/In Game
</h5>
<h3 class="text-success">` + betInGameCount + `</h3>
</div>
</div>
</div>
<div class="col-md-2">
<div class="card text-center">
<div class="card-body">
<h5 class="card-title text-dark">
<i class="fas fa-stop me-2"></i>End
</h5>
<h3 class="text-dark">` + endCount + `</h3>
</div>
</div>
</div>
`;
} }
function filterFixtures() { function filterFixtures() {
const statusFilter = document.getElementById('status-filter').value;
const uploadFilter = document.getElementById('upload-filter').value; const uploadFilter = document.getElementById('upload-filter').value;
const pastFilter = document.getElementById('past-filter').value;
const searchTerm = document.getElementById('search-input').value.toLowerCase(); const searchTerm = document.getElementById('search-input').value.toLowerCase();
// Get today's date (start of day)
const today = new Date();
today.setHours(0, 0, 0, 0);
let filteredFixtures = allFixtures.filter(fixture => { let filteredFixtures = allFixtures.filter(fixture => {
// Status filter // Past fixtures filter
if (statusFilter) { if (pastFilter === 'hide') {
if (statusFilter === 'pending' && (fixture.running || fixture.done)) return false; // Hide past fixtures by default
if (statusFilter === 'running' && (!fixture.running || fixture.done)) return false; if (fixture.start_time) {
if (statusFilter === 'done' && !fixture.done) return false; const fixtureDate = new Date(fixture.start_time);
fixtureDate.setHours(0, 0, 0, 0);
if (fixtureDate < today) {
return false; // Hide past fixtures
}
}
// Show fixtures with no start_time or future/today start_time
} }
// If pastFilter is 'show', show all fixtures regardless of date
// Upload filter
if (uploadFilter && fixture.zip_upload_status !== uploadFilter) { // Upload filter - check the first match's upload status
return false; if (uploadFilter && fixture.matches && fixture.matches.length > 0) {
if (fixture.matches[0].zip_upload_status !== uploadFilter) {
return false;
}
} }
// Search filter // Search filter
if (searchTerm) { if (searchTerm) {
const searchText = (fixture.fighter1_township + ' ' + fixture.fighter2_township + ' ' + fixture.venue_kampala_township).toLowerCase(); const searchText = (fixture.fighter1_township + ' ' + fixture.fighter2_township + ' ' + fixture.venue_kampala_township).toLowerCase();
if (!searchText.includes(searchTerm)) return false; if (!searchText.includes(searchTerm)) return false;
} }
return true; return true;
}); });
renderFixturesTable(filteredFixtures); renderFixturesTable(filteredFixtures);
document.getElementById('filtered-count').textContent = filteredFixtures.length + ' fixtures'; document.getElementById('filtered-count').textContent = filteredFixtures.length + ' fixtures';
// Show/hide empty state // Show/hide empty state
const emptyState = document.getElementById('empty-state'); const emptyState = document.getElementById('empty-state');
const fixturesTable = document.querySelector('.card .table-responsive').parentElement; const fixturesTable = document.querySelector('.card .table-responsive').parentElement;
if (filteredFixtures.length === 0 && allFixtures.length === 0) { if (filteredFixtures.length === 0 && allFixtures.length === 0) {
emptyState.style.display = 'block'; emptyState.style.display = 'block';
fixturesTable.style.display = 'none'; fixturesTable.style.display = 'none';
...@@ -254,54 +347,126 @@ function filterFixtures() { ...@@ -254,54 +347,126 @@ function filterFixtures() {
function renderFixturesTable(fixtures) { function renderFixturesTable(fixtures) {
const tbody = document.getElementById('fixtures-tbody'); const tbody = document.getElementById('fixtures-tbody');
tbody.innerHTML = ''; const existingRows = Array.from(tbody.children);
// Create a map of existing fixtures for comparison
const existingFixtures = new Map();
existingRows.forEach(row => {
const fixtureId = row.getAttribute('data-fixture-id');
if (fixtureId) {
existingFixtures.set(fixtureId, row);
}
});
// Track which fixtures we've processed
const processedFixtures = new Set();
fixtures.forEach(fixture => { fixtures.forEach((fixture, index) => {
const row = document.createElement('tr'); const fixtureId = fixture.fixture_id.toString();
row.innerHTML = ` processedFixtures.add(fixtureId);
<td><strong>#${fixture.match_number}</strong></td>
const startTimeDisplay = fixture.start_time ? new Date(fixture.start_time).toLocaleString() : 'Not set';
const newRowHTML = `
<td>
<strong>#` + fixture.match_number + `</strong>
<br>
<small class="text-muted">` + fixture.match_count + ` matches</small>
</td>
<td> <td>
<div class="fw-bold">${fixture.fighter1_township}</div> <div class="fw-bold">` + fixture.fighter1_township + `</div>
<small class="text-muted">vs</small> <small class="text-muted">vs</small>
<div class="fw-bold">${fixture.fighter2_township}</div> <div class="fw-bold">` + fixture.fighter2_township + `</div>
</td>
<td>` + fixture.venue_kampala_township + `</td>
<td>` + getFixtureStatusBadge(fixture) + `</td>
<td>` + getUploadStatusBadge(fixture) + `</td>
<td>
<small class="text-info">` + startTimeDisplay + `</small>
</td> </td>
<td>${fixture.venue_kampala_township}</td>
<td>${getStatusBadge(fixture)}</td>
<td>${getUploadStatusBadge(fixture)}</td>
<td> <td>
<small class="text-muted"> <small class="text-muted">
${new Date(fixture.created_at).toLocaleString()} ` + new Date(fixture.created_at).toLocaleString() + `
</small> </small>
</td> </td>
<td> <td>
<a href="/fixtures/${fixture.id}" class="btn btn-sm btn-outline-primary"> <a href="/fixtures/` + fixture.fixture_id + `" class="btn btn-sm btn-outline-primary">
<i class="fas fa-eye me-1"></i>Details <i class="fas fa-eye me-1"></i>Details
</a> </a>
</td> </td>
`; `;
tbody.appendChild(row);
const existingRow = existingFixtures.get(fixtureId);
if (existingRow) {
// Update existing row only if content has changed
if (existingRow.innerHTML !== newRowHTML) {
existingRow.innerHTML = newRowHTML;
existingRow.style.backgroundColor = '#fff3cd'; // Highlight changed row briefly
setTimeout(() => {
existingRow.style.backgroundColor = '';
}, 1000);
}
} else {
// Add new row
const row = document.createElement('tr');
row.setAttribute('data-fixture-id', fixtureId);
row.innerHTML = newRowHTML;
row.style.backgroundColor = '#d4edda'; // Highlight new row briefly
tbody.appendChild(row);
setTimeout(() => {
row.style.backgroundColor = '';
}, 1000);
}
});
// Remove rows that are no longer in the data
existingFixtures.forEach((row, fixtureId) => {
if (!processedFixtures.has(fixtureId)) {
row.style.backgroundColor = '#f8d7da'; // Highlight removed row briefly
setTimeout(() => {
if (row.parentNode) {
row.parentNode.removeChild(row);
}
}, 500);
}
}); });
} }
function getStatusBadge(fixture) { function getFixtureStatusBadge(fixture) {
if (fixture.done) { const status = fixture.fixture_status;
return '<span class="badge bg-success"><i class="fas fa-check me-1"></i>Completed</span>';
} else if (fixture.running) { switch (status) {
return '<span class="badge bg-info"><i class="fas fa-play me-1"></i>Running</span>'; case 'pending':
} else { return '<span class="badge bg-warning"><i class="fas fa-clock me-1"></i>Pending</span>';
return '<span class="badge bg-warning"><i class="fas fa-clock me-1"></i>Pending</span>'; case 'running':
return '<span class="badge bg-info"><i class="fas fa-play me-1"></i>Running</span>';
case 'scheduled':
return '<span class="badge bg-secondary"><i class="fas fa-calendar me-1"></i>Scheduled</span>';
case 'bet':
return '<span class="badge bg-primary"><i class="fas fa-money-bill me-1"></i>Bet</span>';
case 'ingame':
return '<span class="badge bg-success"><i class="fas fa-gamepad me-1"></i>In Game</span>';
case 'end':
return '<span class="badge bg-dark"><i class="fas fa-stop me-1"></i>End</span>';
default:
return '<span class="badge bg-secondary"><i class="fas fa-question me-1"></i>Unknown</span>';
} }
} }
function getUploadStatusBadge(fixture) { function getUploadStatusBadge(fixture) {
const status = fixture.zip_upload_status || 'pending'; // Get upload status from the first match in the fixture
const progress = fixture.zip_upload_progress || 0; if (!fixture.matches || fixture.matches.length === 0) {
return '<span class="badge bg-secondary"><i class="fas fa-clock me-1"></i>Pending</span>';
}
const firstMatch = fixture.matches[0];
const status = firstMatch.zip_upload_status || 'pending';
const progress = firstMatch.zip_upload_progress || 0;
switch (status) { switch (status) {
case 'completed': case 'completed':
return '<span class="badge bg-success"><i class="fas fa-check me-1"></i>Completed</span>'; return '<span class="badge bg-success"><i class="fas fa-check me-1"></i>Completed</span>';
case 'uploading': case 'uploading':
return `<span class="badge bg-info"><i class="fas fa-spinner fa-spin me-1"></i>Uploading (${progress.toFixed(1)}%)</span>`; return '<span class="badge bg-info"><i class="fas fa-spinner fa-spin me-1"></i>Uploading (' + progress.toFixed(1) + '%)</span>';
case 'failed': case 'failed':
return '<span class="badge bg-danger"><i class="fas fa-times me-1"></i>Failed</span>'; return '<span class="badge bg-danger"><i class="fas fa-times me-1"></i>Failed</span>';
default: default:
......
...@@ -38,7 +38,65 @@ ...@@ -38,7 +38,65 @@
</button> </button>
</div> </div>
</div> </div>
<!-- Match Timer and Interval Configuration -->
<div class="row mt-3">
<div class="col-md-8">
<div class="input-group">
<span class="input-group-text">
<i class="fas fa-clock me-2"></i>Match Interval
</span>
<input type="number" class="form-control" id="match-interval"
placeholder="20" min="1" max="60" step="1" value="20">
<span class="input-group-text">minutes</span>
<button class="btn btn-outline-primary" id="btn-save-interval">
<i class="fas fa-save me-1"></i>Save
</button>
</div>
<small class="text-muted mt-1 d-block">
Set the interval between matches in minutes (1-60)
</small>
</div>
<div class="col-md-4">
<div class="card bg-light">
<div class="card-body text-center p-3">
<h5 class="card-title mb-2">
<i class="fas fa-stopwatch me-2"></i>Match Timer
</h5>
<div class="h3 mb-2" id="admin-match-timer">--:--</div>
<div class="mt-2">
<small class="text-muted" id="timer-status">Waiting for games to start...</small>
</div>
<div class="mt-2">
<small class="text-info">
<i class="fas fa-info-circle me-1"></i>Timer starts automatically when cashier begins games
</small>
</div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card bg-light">
<div class="card-body p-3">
<h5 class="card-title mb-3">
<i class="fas fa-dice me-2"></i>Betting Mode
</h5>
<div class="mb-3">
<select class="form-select" id="betting-mode-select">
<option value="all_bets_on_start">All bets on start</option>
<option value="one_bet_at_a_time">One bet at a time</option>
</select>
</div>
<div class="mt-2">
<small class="text-muted">
<i class="fas fa-info-circle me-1"></i>Choose how bets are placed during matches
</small>
</div>
</div>
</div>
</div>
</div>
{% if current_user.is_admin %} {% if current_user.is_admin %}
<!-- Admin Only Actions --> <!-- Admin Only Actions -->
<div class="row mt-3 pt-3 border-top"> <div class="row mt-3 pt-3 border-top">
...@@ -237,6 +295,12 @@ ...@@ -237,6 +295,12 @@
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
// Load available templates on page load // Load available templates on page load
loadAvailableTemplates(); loadAvailableTemplates();
// Load match interval configuration
loadMatchInterval();
// Load betting mode configuration
loadBettingMode();
// Quick action buttons // Quick action buttons
document.getElementById('btn-play-video').addEventListener('click', function() { document.getElementById('btn-play-video').addEventListener('click', function() {
...@@ -250,6 +314,16 @@ document.addEventListener('DOMContentLoaded', function() { ...@@ -250,6 +314,16 @@ document.addEventListener('DOMContentLoaded', function() {
document.getElementById('btn-create-token').addEventListener('click', function() { document.getElementById('btn-create-token').addEventListener('click', function() {
window.location.href = '/tokens'; window.location.href = '/tokens';
}); });
// Match interval save button
document.getElementById('btn-save-interval').addEventListener('click', function() {
saveMatchInterval();
});
// Betting mode select
document.getElementById('betting-mode-select').addEventListener('change', function() {
saveBettingMode(this.value);
});
// Admin shutdown button (only exists if user is admin) // Admin shutdown button (only exists if user is admin)
const shutdownBtn = document.getElementById('btn-shutdown-app'); const shutdownBtn = document.getElementById('btn-shutdown-app');
...@@ -393,23 +467,127 @@ document.addEventListener('DOMContentLoaded', function() { ...@@ -393,23 +467,127 @@ document.addEventListener('DOMContentLoaded', function() {
// Initial status update // Initial status update
updateSystemStatus(); updateSystemStatus();
updateVideoStatus(); updateVideoStatus();
// Periodic updates // Periodic updates
setInterval(updateSystemStatus, 30000); // 30 seconds setInterval(updateSystemStatus, 5000); // 5 seconds
setInterval(updateVideoStatus, 10000); // 10 seconds setInterval(updateVideoStatus, 5000); // 5 seconds
}); });
function loadMatchInterval() {
fetch('/api/config/match-interval')
.then(response => response.json())
.then(data => {
if (data.success && data.match_interval) {
document.getElementById('match-interval').value = data.match_interval;
}
})
.catch(error => {
console.error('Error loading match interval:', error);
// Keep default value if loading fails
});
}
function saveMatchInterval() {
const intervalInput = document.getElementById('match-interval');
const intervalValue = parseInt(intervalInput.value);
if (!intervalValue || intervalValue < 1 || intervalValue > 60) {
alert('Please enter a valid interval between 1 and 60 minutes');
return;
}
const saveBtn = document.getElementById('btn-save-interval');
const originalText = saveBtn.innerHTML;
// Show loading state
saveBtn.disabled = true;
saveBtn.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>Saving...';
fetch('/api/config/match-interval', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
match_interval: intervalValue
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
// Reload the match interval to confirm the saved value
loadMatchInterval();
alert('Match interval saved successfully!');
} else {
alert('Failed to save match interval: ' + (data.error || 'Unknown error'));
}
})
.catch(error => {
alert('Error saving match interval: ' + error.message);
})
.finally(() => {
// Restore button state
saveBtn.disabled = false;
saveBtn.innerHTML = originalText;
});
}
function loadBettingMode() {
fetch('/api/betting-mode')
.then(response => response.json())
.then(data => {
if (data.success) {
const select = document.getElementById('betting-mode-select');
if (select) {
select.value = data.betting_mode;
}
} else {
console.error('Failed to load betting mode:', data.error);
}
})
.catch(error => {
console.error('Error loading betting mode:', error);
});
}
function saveBettingMode(mode) {
fetch('/api/betting-mode', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
betting_mode: mode
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
console.log('Betting mode saved successfully:', mode);
} else {
console.error('Failed to save betting mode:', data.error);
// Revert the select if save failed
loadBettingMode();
}
})
.catch(error => {
console.error('Error saving betting mode:', error);
// Revert the select if save failed
loadBettingMode();
});
}
function loadAvailableTemplates() { function loadAvailableTemplates() {
fetch('/api/templates') fetch('/api/templates')
.then(response => response.json()) .then(response => response.json())
.then(data => { .then(data => {
const videoTemplateSelect = document.getElementById('video-template'); const videoTemplateSelect = document.getElementById('video-template');
const overlayTemplateSelect = document.getElementById('overlay-template'); const overlayTemplateSelect = document.getElementById('overlay-template');
// Clear loading options // Clear loading options
videoTemplateSelect.innerHTML = ''; videoTemplateSelect.innerHTML = '';
overlayTemplateSelect.innerHTML = ''; overlayTemplateSelect.innerHTML = '';
if (data.templates && Array.isArray(data.templates)) { if (data.templates && Array.isArray(data.templates)) {
data.templates.forEach(template => { data.templates.forEach(template => {
// Add to video template select // Add to video template select
...@@ -417,20 +595,20 @@ function loadAvailableTemplates() { ...@@ -417,20 +595,20 @@ function loadAvailableTemplates() {
videoOption.value = template.name; videoOption.value = template.name;
videoOption.textContent = template.display_name || template.name; videoOption.textContent = template.display_name || template.name;
videoTemplateSelect.appendChild(videoOption); videoTemplateSelect.appendChild(videoOption);
// Add to overlay template select // Add to overlay template select
const overlayOption = document.createElement('option'); const overlayOption = document.createElement('option');
overlayOption.value = template.name; overlayOption.value = template.name;
overlayOption.textContent = template.display_name || template.name; overlayOption.textContent = template.display_name || template.name;
overlayTemplateSelect.appendChild(overlayOption); overlayTemplateSelect.appendChild(overlayOption);
}); });
// Select default template if available // Select default template if available
const defaultVideoOption = videoTemplateSelect.querySelector('option[value="default"]'); const defaultVideoOption = videoTemplateSelect.querySelector('option[value="default"]');
if (defaultVideoOption) { if (defaultVideoOption) {
defaultVideoOption.selected = true; defaultVideoOption.selected = true;
} }
const defaultOverlayOption = overlayTemplateSelect.querySelector('option[value="default"]'); const defaultOverlayOption = overlayTemplateSelect.querySelector('option[value="default"]');
if (defaultOverlayOption) { if (defaultOverlayOption) {
defaultOverlayOption.selected = true; defaultOverlayOption.selected = true;
...@@ -441,7 +619,7 @@ function loadAvailableTemplates() { ...@@ -441,7 +619,7 @@ function loadAvailableTemplates() {
videoOption.value = 'default'; videoOption.value = 'default';
videoOption.textContent = 'Default'; videoOption.textContent = 'Default';
videoTemplateSelect.appendChild(videoOption); videoTemplateSelect.appendChild(videoOption);
const overlayOption = document.createElement('option'); const overlayOption = document.createElement('option');
overlayOption.value = 'default'; overlayOption.value = 'default';
overlayOption.textContent = 'Default'; overlayOption.textContent = 'Default';
...@@ -453,10 +631,11 @@ function loadAvailableTemplates() { ...@@ -453,10 +631,11 @@ function loadAvailableTemplates() {
// Fallback template options // Fallback template options
const videoTemplateSelect = document.getElementById('video-template'); const videoTemplateSelect = document.getElementById('video-template');
const overlayTemplateSelect = document.getElementById('overlay-template'); const overlayTemplateSelect = document.getElementById('overlay-template');
videoTemplateSelect.innerHTML = '<option value="default">Default</option>'; videoTemplateSelect.innerHTML = '<option value="default">Default</option>';
overlayTemplateSelect.innerHTML = '<option value="default">Default</option>'; overlayTemplateSelect.innerHTML = '<option value="default">Default</option>';
}); });
} }
</script> </script>
{% endblock %} {% endblock %}
{% extends "base.html" %}
{% block title %}{{ page_title }}{% endblock %}
{% block content %}
<div class="container-fluid">
<div class="row">
<div class="col-12">
<!-- Navigation -->
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{{ url_for('main.fixtures') }}">Fixtures</a></li>
<li class="breadcrumb-item"><a href="#" onclick="goBackToFixture()">Fixture Details</a></li>
<li class="breadcrumb-item active" aria-current="page">Match Details</li>
</ol>
</nav>
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h1><i class="fas fa-boxing me-2"></i>Match Details</h1>
<p class="mb-0 text-muted">Complete match information, outcomes, and results</p>
</div>
<a href="javascript:history.back()" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left me-1"></i>Back
</a>
</div>
<!-- Loading Spinner -->
<div id="loading" class="text-center my-5">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<p class="mt-2">Loading match details...</p>
</div>
<!-- Error Message -->
<div id="error-message" class="alert alert-danger" style="display: none;">
<i class="fas fa-exclamation-triangle me-2"></i>
<span id="error-text"></span>
</div>
<!-- Match Details Content -->
<div id="match-content" style="display: none;">
<!-- Match Information Card -->
<div class="row">
<div class="col-12">
<div class="card mb-4">
<div class="card-header">
<h5><i class="fas fa-info-circle me-2"></i>Match Information</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<table class="table table-borderless">
<tr>
<td><strong>Match #:</strong></td>
<td><span class="badge bg-primary fs-6" id="match-number"></span></td>
</tr>
<tr>
<td><strong>Status:</strong></td>
<td><span id="match-status-badge"></span></td>
</tr>
<tr>
<td><strong>Fighters:</strong></td>
<td><span id="fighters"></span></td>
</tr>
<tr>
<td><strong>Venue:</strong></td>
<td><span id="venue"></span></td>
</tr>
</table>
</div>
<div class="col-md-6">
<table class="table table-borderless">
<tr>
<td><strong>Start Time:</strong></td>
<td><span id="start-time" class="text-muted">Not set</span></td>
</tr>
<tr>
<td><strong>End Time:</strong></td>
<td><span id="end-time" class="text-muted">Not set</span></td>
</tr>
<tr>
<td><strong>Result:</strong></td>
<td><span id="result" class="text-muted">Not available</span></td>
</tr>
<tr>
<td><strong>Fixture ID:</strong></td>
<td><small class="text-muted" id="fixture-id"></small></td>
</tr>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Match Outcomes -->
<div class="row">
<div class="col-12">
<div class="card mb-4">
<div class="card-header d-flex justify-content-between align-items-center">
<h5><i class="fas fa-chart-line me-2"></i>Match Outcomes</h5>
<span id="outcomes-count" class="badge bg-secondary">0 outcomes</span>
</div>
<div class="card-body">
<div id="no-outcomes" class="text-center text-muted py-4" style="display: none;">
<i class="fas fa-chart-line fa-3x mb-3"></i>
<h5>No Outcomes Available</h5>
<p>No outcome data has been synchronized for this match yet.</p>
</div>
<div id="outcomes-container" style="display: none;">
<div class="table-responsive">
<table class="table table-hover">
<thead class="table-light">
<tr>
<th>Column Name</th>
<th>Value</th>
<th>Updated</th>
</tr>
</thead>
<tbody id="outcomes-tbody">
<!-- Outcomes will be loaded here -->
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- File Information -->
<div class="row">
<div class="col-md-6">
<div class="card mb-4">
<div class="card-header">
<h5><i class="fas fa-file me-2"></i>Source File</h5>
</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label">Fixture File</label>
<div class="small text-muted">
<div><strong>Filename:</strong> <span id="filename"></span></div>
<div><strong>SHA1:</strong> <span id="file-sha1sum" class="font-monospace"></span></div>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card mb-4">
<div class="card-header">
<h5><i class="fas fa-upload me-2"></i>Upload Status</h5>
</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label">ZIP Upload</label>
<div id="upload-status"></div>
</div>
</div>
</div>
</div>
</div>
<!-- Timestamps -->
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5><i class="fas fa-clock me-2"></i>Timestamps</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-4">
<label class="form-label small">Created</label>
<div class="text-muted small" id="created-at"></div>
</div>
<div class="col-md-4">
<label class="form-label small">Updated</label>
<div class="text-muted small" id="updated-at"></div>
</div>
<div class="col-md-4">
<label class="form-label small">Active Time</label>
<div class="text-muted small" id="active-time"></div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<script id="match-config" type="application/json">
{
"matchId": {{ match_id | tojson }},
"fixtureId": {{ fixture_id | tojson }}
}
</script>
<script>
const config = JSON.parse(document.getElementById('match-config').textContent);
const matchId = config.matchId;
const fixtureId = config.fixtureId;
let cachedMatchData = null;
let isInitialLoad = true;
// Load match details on page load
document.addEventListener('DOMContentLoaded', function() {
loadMatchDetails();
// Set up auto-refresh every 5 seconds
setInterval(loadMatchDetails, 5000);
});
function loadMatchDetails() {
const loading = document.getElementById('loading');
const errorMessage = document.getElementById('error-message');
const content = document.getElementById('match-content');
// Only show loading state on initial load
if (isInitialLoad) {
loading.style.display = 'block';
errorMessage.style.display = 'none';
content.style.display = 'none';
}
// First get fixture details to get all matches, then find the specific match
fetch(`/api/fixtures/${fixtureId}`)
.then(response => response.json())
.then(data => {
if (data.success) {
// Find the specific match within the fixture
const match = data.matches.find(m => m.id === matchId);
if (match) {
// Check if data has actually changed
const currentMatchString = JSON.stringify(match);
if (cachedMatchData === currentMatchString && !isInitialLoad) {
console.log('📦 No changes in match data, skipping update');
return;
}
cachedMatchData = currentMatchString;
if (isInitialLoad) {
renderMatchDetails(match);
content.style.display = 'block';
} else {
updateMatchDetails(match);
}
} else {
if (isInitialLoad) {
showError('Match not found in fixture');
}
}
} else {
if (isInitialLoad) {
showError(data.error || 'Failed to load fixture details');
}
}
})
.catch(error => {
console.error('Error:', error);
if (isInitialLoad) {
showError('Network error: ' + error.message);
}
})
.finally(() => {
if (isInitialLoad) {
loading.style.display = 'none';
isInitialLoad = false;
}
});
}
function showError(message) {
document.getElementById('error-text').textContent = message;
document.getElementById('error-message').style.display = 'block';
}
function updateMatchDetails(match) {
// Update status badge with highlighting
const statusBadge = document.getElementById('match-status-badge');
const newStatusBadge = getStatusBadge(match);
if (statusBadge.innerHTML !== newStatusBadge) {
statusBadge.innerHTML = newStatusBadge;
statusBadge.style.backgroundColor = '#fff3cd';
setTimeout(() => {
statusBadge.style.backgroundColor = '';
}, 1000);
}
// Update start time if changed
if (match.start_time) {
const startTimeEl = document.getElementById('start-time');
const newStartTime = new Date(match.start_time).toLocaleString();
if (startTimeEl.textContent !== newStartTime) {
startTimeEl.textContent = newStartTime;
startTimeEl.classList.remove('text-muted');
startTimeEl.style.backgroundColor = '#fff3cd';
setTimeout(() => {
startTimeEl.style.backgroundColor = '';
}, 1000);
}
}
// Update end time if changed
if (match.end_time) {
const endTimeEl = document.getElementById('end-time');
const newEndTime = new Date(match.end_time).toLocaleString();
if (endTimeEl.textContent !== newEndTime) {
endTimeEl.textContent = newEndTime;
endTimeEl.classList.remove('text-muted');
endTimeEl.style.backgroundColor = '#fff3cd';
setTimeout(() => {
endTimeEl.style.backgroundColor = '';
}, 1000);
}
}
// Update result if changed
if (match.result) {
const resultEl = document.getElementById('result');
if (resultEl.textContent !== match.result) {
resultEl.textContent = match.result;
resultEl.classList.remove('text-muted');
resultEl.style.backgroundColor = '#fff3cd';
setTimeout(() => {
resultEl.style.backgroundColor = '';
}, 1000);
}
}
// Update upload status
const uploadStatusEl = document.getElementById('upload-status');
const newUploadStatus = getUploadStatusBadge(match);
if (uploadStatusEl.innerHTML !== newUploadStatus) {
uploadStatusEl.innerHTML = newUploadStatus;
uploadStatusEl.style.backgroundColor = '#fff3cd';
setTimeout(() => {
uploadStatusEl.style.backgroundColor = '';
}, 1000);
}
// Update timestamps
const updatedAtEl = document.getElementById('updated-at');
const newUpdatedAt = new Date(match.updated_at).toLocaleString();
if (updatedAtEl.textContent !== newUpdatedAt) {
updatedAtEl.textContent = newUpdatedAt;
updatedAtEl.style.backgroundColor = '#fff3cd';
setTimeout(() => {
updatedAtEl.style.backgroundColor = '';
}, 1000);
}
// Update outcomes
renderOutcomes(match.outcomes || []);
}
function renderMatchDetails(match) {
// Basic match information
document.getElementById('match-number').textContent = match.match_number;
document.getElementById('match-status-badge').innerHTML = getStatusBadge(match);
document.getElementById('fighters').textContent = match.fighter1_township + ' vs ' + match.fighter2_township;
document.getElementById('venue').textContent = match.venue_kampala_township;
document.getElementById('fixture-id').textContent = match.fixture_id;
// Start time
if (match.start_time) {
document.getElementById('start-time').textContent = new Date(match.start_time).toLocaleString();
document.getElementById('start-time').classList.remove('text-muted');
}
// End time
if (match.end_time) {
document.getElementById('end-time').textContent = new Date(match.end_time).toLocaleString();
document.getElementById('end-time').classList.remove('text-muted');
}
// Result
if (match.result) {
document.getElementById('result').textContent = match.result;
document.getElementById('result').classList.remove('text-muted');
}
// File information
document.getElementById('filename').textContent = match.filename;
document.getElementById('file-sha1sum').textContent = match.file_sha1sum;
// Upload status
document.getElementById('upload-status').innerHTML = getUploadStatusBadge(match);
// Timestamps
document.getElementById('created-at').textContent = new Date(match.created_at).toLocaleString();
document.getElementById('updated-at').textContent = new Date(match.updated_at).toLocaleString();
// Active time
if (match.fixture_active_time) {
document.getElementById('active-time').textContent = new Date(match.fixture_active_time * 1000).toLocaleString();
}
// Render outcomes
renderOutcomes(match.outcomes || []);
}
function renderOutcomes(outcomes) {
const outcomesCount = document.getElementById('outcomes-count');
const noOutcomes = document.getElementById('no-outcomes');
const outcomesContainer = document.getElementById('outcomes-container');
const tbody = document.getElementById('outcomes-tbody');
outcomesCount.textContent = outcomes.length + ' outcomes';
if (outcomes.length === 0) {
noOutcomes.style.display = 'block';
outcomesContainer.style.display = 'none';
return;
}
noOutcomes.style.display = 'none';
outcomesContainer.style.display = 'block';
tbody.innerHTML = '';
outcomes.forEach(outcome => {
const row = document.createElement('tr');
const updatedTime = new Date(outcome.updated_at).toLocaleString();
row.innerHTML = `
<td><strong>${outcome.column_name}</strong></td>
<td><span class="badge bg-primary">${outcome.float_value}</span></td>
<td><small class="text-muted">${updatedTime}</small></td>
`;
tbody.appendChild(row);
});
}
function getStatusBadge(match) {
const status = match.status;
switch (status) {
case 'pending':
return '<span class="badge bg-warning"><i class="fas fa-clock me-1"></i>Pending</span>';
case 'scheduled':
return '<span class="badge bg-secondary"><i class="fas fa-calendar me-1"></i>Scheduled</span>';
case 'bet':
return '<span class="badge bg-primary"><i class="fas fa-money-bill me-1"></i>Bet</span>';
case 'ingame':
return '<span class="badge bg-success"><i class="fas fa-gamepad me-1"></i>In Game</span>';
case 'end':
return '<span class="badge bg-dark"><i class="fas fa-stop me-1"></i>End</span>';
case 'cancelled':
return '<span class="badge bg-danger"><i class="fas fa-times me-1"></i>Cancelled</span>';
case 'failed':
return '<span class="badge bg-danger"><i class="fas fa-exclamation-triangle me-1"></i>Failed</span>';
case 'paused':
return '<span class="badge bg-warning"><i class="fas fa-pause me-1"></i>Paused</span>';
default:
return '<span class="badge bg-secondary"><i class="fas fa-question me-1"></i>Unknown</span>';
}
}
function getUploadStatusBadge(match) {
const status = match.zip_upload_status || 'pending';
const progress = match.zip_upload_progress || 0;
switch (status) {
case 'completed':
return '<span class="badge bg-success"><i class="fas fa-check me-1"></i>Completed</span>';
case 'uploading':
return `<span class="badge bg-info"><i class="fas fa-spinner fa-spin me-1"></i>Uploading (${progress.toFixed(1)}%)</span>`;
case 'failed':
return '<span class="badge bg-danger"><i class="fas fa-times me-1"></i>Failed</span>';
default:
return '<span class="badge bg-secondary"><i class="fas fa-clock me-1"></i>Pending</span>';
}
}
function goBackToFixture() {
// Use the fixture_id from the config
if (fixtureId) {
window.location.href = `/fixtures/${fixtureId}`;
} else {
// Fallback to browser back
window.history.back();
}
}
</script>
{% endblock %}
\ No newline at end of file
let allFixtures = [];
// Load fixtures on page load
document.addEventListener('DOMContentLoaded', function() {
loadFixtures();
// Event listeners
document.getElementById('refresh-btn').addEventListener('click', loadFixtures);
document.getElementById('status-filter').addEventListener('change', filterFixtures);
document.getElementById('upload-filter').addEventListener('change', filterFixtures);
document.getElementById('search-input').addEventListener('input', filterFixtures);
// Reset fixtures button (admin only)
const resetBtn = document.getElementById('reset-fixtures-btn');
if (resetBtn) {
resetBtn.addEventListener('click', resetFixtures);
}
});
function loadFixtures() {
const loading = document.getElementById('loading');
const refreshBtn = document.getElementById('refresh-btn');
loading.style.display = 'block';
refreshBtn.disabled = true;
fetch('/api/fixtures')
.then(response => response.json())
.then(data => {
if (data.success) {
allFixtures = data.fixtures;
updateSummaryCards();
filterFixtures(); // This will also render the table
} else {
alert('Error loading fixtures: ' + (data.error || 'Unknown error'));
}
})
.catch(error => {
console.error('Error:', error);
alert('Failed to load fixtures: ' + error.message);
})
.finally(() => {
loading.style.display = 'none';
refreshBtn.disabled = false;
});
}
function updateSummaryCards() {
const totalCount = allFixtures.length;
const pendingCount = allFixtures.filter(f => f.fixture_status === 'pending').length;
const runningCount = allFixtures.filter(f => f.fixture_status === 'running').length;
const scheduledCount = allFixtures.filter(f => f.fixture_status === 'scheduled').length;
const betCount = allFixtures.filter(f => f.fixture_status === 'bet').length;
const ingameCount = allFixtures.filter(f => f.fixture_status === 'ingame').length;
const endCount = allFixtures.filter(f => f.fixture_status === 'end').length;
document.getElementById('total-count').textContent = totalCount;
document.getElementById('pending-count').textContent = pendingCount;
document.getElementById('running-count').textContent = runningCount;
// Update the summary cards to show more status types
const summaryCards = document.getElementById('summary-cards');
const betInGameCount = betCount + ingameCount;
summaryCards.innerHTML = `
<div class="col-md-2">
<div class="card text-center">
<div class="card-body">
<h5 class="card-title text-primary">
<i class="fas fa-list me-2"></i>Total Fixtures
</h5>
<h3 id="total-count" class="text-primary">` + totalCount + `</h3>
</div>
</div>
</div>
<div class="col-md-2">
<div class="card text-center">
<div class="card-body">
<h5 class="card-title text-warning">
<i class="fas fa-clock me-2"></i>Pending
</h5>
<h3 id="pending-count" class="text-warning">` + pendingCount + `</h3>
</div>
</div>
</div>
<div class="col-md-2">
<div class="card text-center">
<div class="card-body">
<h5 class="card-title text-info">
<i class="fas fa-play me-2"></i>Running
</h5>
<h3 id="running-count" class="text-info">` + runningCount + `</h3>
</div>
</div>
</div>
<div class="col-md-2">
<div class="card text-center">
<div class="card-body">
<h5 class="card-title text-secondary">
<i class="fas fa-calendar me-2"></i>Scheduled
</h5>
<h3 class="text-secondary">` + scheduledCount + `</h3>
</div>
</div>
</div>
<div class="col-md-2">
<div class="card text-center">
<div class="card-body">
<h5 class="card-title text-success">
<i class="fas fa-gamepad me-2"></i>Bet/In Game
</h5>
<h3 class="text-success">` + betInGameCount + `</h3>
</div>
</div>
</div>
<div class="col-md-2">
<div class="card text-center">
<div class="card-body">
<h5 class="card-title text-dark">
<i class="fas fa-stop me-2"></i>End
</h5>
<h3 class="text-dark">` + endCount + `</h3>
</div>
</div>
</div>
`;
}
function filterFixtures() {
const statusFilter = document.getElementById('status-filter').value;
const uploadFilter = document.getElementById('upload-filter').value;
const searchTerm = document.getElementById('search-input').value.toLowerCase();
let filteredFixtures = allFixtures.filter(fixture => {
// Status filter
if (statusFilter && fixture.fixture_status !== statusFilter) {
return false;
}
// Upload filter - check the first match's upload status
if (uploadFilter && fixture.matches && fixture.matches.length > 0) {
if (fixture.matches[0].zip_upload_status !== uploadFilter) {
return false;
}
}
// Search filter
if (searchTerm) {
const searchText = (fixture.fighter1_township + ' ' + fixture.fighter2_township + ' ' + fixture.venue_kampala_township).toLowerCase();
if (!searchText.includes(searchTerm)) return false;
}
return true;
});
renderFixturesTable(filteredFixtures);
document.getElementById('filtered-count').textContent = filteredFixtures.length + ' fixtures';
// Show/hide empty state
const emptyState = document.getElementById('empty-state');
const fixturesTable = document.querySelector('.card .table-responsive').parentElement;
if (filteredFixtures.length === 0 && allFixtures.length === 0) {
emptyState.style.display = 'block';
fixturesTable.style.display = 'none';
} else {
emptyState.style.display = 'none';
fixturesTable.style.display = 'block';
}
}
function renderFixturesTable(fixtures) {
const tbody = document.getElementById('fixtures-tbody');
tbody.innerHTML = '';
fixtures.forEach(fixture => {
const row = document.createElement('tr');
const startTimeInfo = fixture.start_time ? '<br><small class="text-info">Start: ' + new Date(fixture.start_time).toLocaleString() + '</small>' : '';
row.innerHTML = `
<td>
<strong>#` + fixture.match_number + `</strong>
<br>
<small class="text-muted">` + fixture.match_count + ` matches</small>
</td>
<td>
<div class="fw-bold">` + fixture.fighter1_township + `</div>
<small class="text-muted">vs</small>
<div class="fw-bold">` + fixture.fighter2_township + `</div>
</td>
<td>` + fixture.venue_kampala_township + `</td>
<td>` + getFixtureStatusBadge(fixture) + `</td>
<td>` + getUploadStatusBadge(fixture) + `</td>
<td>
<small class="text-muted">
` + new Date(fixture.created_at).toLocaleString() + `
</small>
` + startTimeInfo + `
</td>
<td>
<a href="/fixtures/` + fixture.id + `" class="btn btn-sm btn-outline-primary">
<i class="fas fa-eye me-1"></i>Details
</a>
</td>
`;
tbody.appendChild(row);
});
}
function getFixtureStatusBadge(fixture) {
const status = fixture.fixture_status;
switch (status) {
case 'pending':
return '<span class="badge bg-warning"><i class="fas fa-clock me-1"></i>Pending</span>';
case 'running':
return '<span class="badge bg-info"><i class="fas fa-play me-1"></i>Running</span>';
case 'scheduled':
return '<span class="badge bg-secondary"><i class="fas fa-calendar me-1"></i>Scheduled</span>';
case 'bet':
return '<span class="badge bg-primary"><i class="fas fa-money-bill me-1"></i>Bet</span>';
case 'ingame':
return '<span class="badge bg-success"><i class="fas fa-gamepad me-1"></i>In Game</span>';
case 'end':
return '<span class="badge bg-dark"><i class="fas fa-stop me-1"></i>End</span>';
default:
return '<span class="badge bg-secondary"><i class="fas fa-question me-1"></i>Unknown</span>';
}
}
function getUploadStatusBadge(fixture) {
// Get upload status from the first match in the fixture
if (!fixture.matches || fixture.matches.length === 0) {
return '<span class="badge bg-secondary"><i class="fas fa-clock me-1"></i>Pending</span>';
}
const firstMatch = fixture.matches[0];
const status = firstMatch.zip_upload_status || 'pending';
const progress = firstMatch.zip_upload_progress || 0;
switch (status) {
case 'completed':
return '<span class="badge bg-success"><i class="fas fa-check me-1"></i>Completed</span>';
case 'uploading':
return `<span class="badge bg-info"><i class="fas fa-spinner fa-spin me-1"></i>Uploading (${progress.toFixed(1)}%)</span>`;
case 'failed':
return '<span class="badge bg-danger"><i class="fas fa-times me-1"></i>Failed</span>';
default:
return '<span class="badge bg-secondary"><i class="fas fa-clock me-1"></i>Pending</span>';
}
}
function resetFixtures() {
const confirmMessage = 'WARNING: This will permanently delete ALL fixture data including:\n\n' +
'• All synchronized matches and outcomes\n' +
'• All downloaded ZIP files\n' +
'• This action cannot be undone!\n\n' +
'Are you sure you want to reset all fixtures data?';
if (!confirm(confirmMessage)) {
return;
}
const resetBtn = document.getElementById('reset-fixtures-btn');
const originalText = resetBtn.innerHTML;
resetBtn.disabled = true;
resetBtn.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>Resetting...';
fetch('/api/fixtures/reset', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
alert(`Fixtures reset successfully!\n\nRemoved:\n• ${data.removed.matches} matches\n• ${data.removed.outcomes} outcomes\n• ${data.removed.zip_files} ZIP files`);
// Reload fixtures to show empty state
loadFixtures();
} else {
alert('Error resetting fixtures: ' + (data.error || 'Unknown error'));
}
})
.catch(error => {
console.error('Error:', error);
alert('Failed to reset fixtures: ' + error.message);
})
.finally(() => {
resetBtn.disabled = false;
resetBtn.innerHTML = originalText;
});
}
#!/usr/bin/env python3
"""
Complete test for cashier dashboard functionality
"""
import sys
import os
import requests
import json
from pathlib import Path
# Add project root to path
project_root = Path(__file__).parent
sys.path.insert(0, str(project_root))
def test_server_connection():
"""Test if server is running and responding"""
print("=== Testing Server Connection ===")
try:
response = requests.get("http://localhost:8080/", timeout=5, allow_redirects=False)
if response.status_code == 302 and "login" in response.headers.get('Location', ''):
print("✅ Server is running and responding (redirects to login)")
return True
elif response.status_code == 200 and "login" in response.text.lower():
print("✅ Server is running and responding (login page loaded)")
return True
else:
print(f"❌ Unexpected response: {response.status_code}")
return False
except requests.exceptions.RequestException as e:
print(f"❌ Server not responding: {e}")
return False
def test_login():
"""Test login functionality"""
print("\n=== Testing Login ===")
try:
# Test login with cashier credentials
login_data = {
'username': 'cashier',
'password': 'cashier'
}
session = requests.Session()
response = session.post("http://localhost:8080/auth/login", data=login_data, timeout=10)
if response.status_code == 302:
print("✅ Login successful")
return session
else:
print(f"❌ Login failed: {response.status_code}")
return None
except Exception as e:
print(f"❌ Login error: {e}")
return None
def test_api_endpoint(session):
"""Test the cashier pending matches API endpoint"""
print("\n=== Testing API Endpoint ===")
try:
response = session.get("http://localhost:8080/api/cashier/pending-matches", timeout=10)
if response.status_code == 200:
data = response.json()
print("✅ API endpoint responding")
print(f" Response: {json.dumps(data, indent=2)}")
if data.get('success'):
print(f" Total matches: {data.get('total', 0)}")
if data.get('matches'):
print(" Sample matches:")
for i, match in enumerate(data['matches'][:2]):
print(f" {i+1}. {match.get('fighter1_township', 'N/A')} vs {match.get('fighter2_township', 'N/A')}")
return True
else:
print(f" API returned error: {data.get('error', 'Unknown error')}")
return False
else:
print(f"❌ API endpoint failed: {response.status_code}")
return False
except Exception as e:
print(f"❌ API error: {e}")
return False
def test_dashboard_access(session):
"""Test accessing the cashier dashboard"""
print("\n=== Testing Dashboard Access ===")
try:
response = session.get("http://localhost:8080/cashier", timeout=10)
if response.status_code == 200:
print("✅ Dashboard accessible")
# Check if the page contains expected elements
content = response.text
if "Cashier Dashboard" in content:
print(" Contains dashboard title")
if "pending-matches-container" in content:
print(" Contains pending matches container")
if "loadPendingMatches" in content:
print(" Contains JavaScript function")
else:
print(" ⚠️ JavaScript function not found in HTML")
return True
else:
print(f"❌ Dashboard access failed: {response.status_code}")
return False
except Exception as e:
print(f"❌ Dashboard error: {e}")
return False
def main():
"""Run all tests"""
print("Cashier Dashboard Complete Test")
print("=" * 40)
# Test 1: Server connection
if not test_server_connection():
print("\n❌ Server is not running. Please start the server first:")
print(" python main.py --web-port 8080 --screen-cast-port 8081 --no-qt")
return
# Test 2: Login
session = test_login()
if not session:
print("\n❌ Cannot proceed without successful login")
return
# Test 3: API endpoint
api_success = test_api_endpoint(session)
# Test 4: Dashboard access
dashboard_success = test_dashboard_access(session)
# Summary
print("\n" + "=" * 40)
print("SUMMARY:")
print(f"Server Connection: ✅")
print(f"Login: ✅")
print(f"API Endpoint: {'✅' if api_success else '❌'}")
print(f"Dashboard Access: {'✅' if dashboard_success else '❌'}")
if api_success and dashboard_success:
print("\n🎉 All tests passed! The cashier dashboard should be working.")
print("\nTo access the dashboard:")
print("1. Open browser to: http://localhost:8080/cashier")
print("2. Login with: cashier / cashier")
print("3. The pending matches should load automatically")
else:
print("\n❌ Some tests failed. Check the output above for details.")
if __name__ == "__main__":
main()
\ No newline at end of file
#!/usr/bin/env python3
"""
Simple test for cashier dashboard functionality
"""
import requests
import json
def test_server():
"""Test if server is responding"""
print("=== Testing Server Connection ===")
try:
response = requests.get("http://localhost:8080/", timeout=5, allow_redirects=False)
if response.status_code == 302:
print("OK Server is running and responding (redirects to login)")
return True
else:
print(f"ERROR Unexpected response: {response.status_code}")
return False
except Exception as e:
print(f"ERROR Server not responding: {e}")
return False
def test_login():
"""Test login with cashier credentials"""
print("\n=== Testing Login ===")
try:
session = requests.Session()
# First get the login page to get CSRF token if needed
login_page = session.get("http://localhost:8080/auth/login", timeout=10)
print(f"Login page status: {login_page.status_code}")
# Try login
login_data = {
'username': 'cashier',
'password': 'cashier123'
}
response = session.post("http://localhost:8080/auth/login",
data=login_data,
timeout=10,
allow_redirects=False)
print(f"Login response status: {response.status_code}")
print(f"Login response location: {response.headers.get('Location', 'N/A')}")
if response.status_code == 302 and 'cashier' in response.headers.get('Location', ''):
print("OK Login successful")
return session
else:
print("ERROR Login failed")
return None
except Exception as e:
print(f"ERROR Login error: {e}")
return None
def test_api_endpoint(session):
"""Test the cashier pending matches API"""
print("\n=== Testing API Endpoint ===")
try:
response = session.get("http://localhost:8080/api/cashier/pending-matches", timeout=10)
print(f"API response status: {response.status_code}")
if response.status_code == 200:
data = response.json()
print("OK API endpoint responding")
print(f"Response: {json.dumps(data, indent=2)}")
if data.get('success'):
print(f"Total matches: {data.get('total', 0)}")
if data.get('matches'):
print("Sample matches:")
for i, match in enumerate(data['matches'][:2]):
print(f" {i+1}. {match.get('fighter1_township', 'N/A')} vs {match.get('fighter2_township', 'N/A')}")
return True
else:
print(f"API returned error: {data.get('error', 'Unknown error')}")
return False
else:
print(f"ERROR API endpoint failed: {response.status_code}")
print(f"Response: {response.text[:200]}...")
return False
except Exception as e:
print(f"ERROR API error: {e}")
return False
def main():
"""Run all tests"""
print("Cashier Dashboard Simple Test")
print("=" * 40)
# Test 1: Server connection
if not test_server():
print("\nERROR Server is not running. Please start the server first:")
print(" python main.py --web-port 8080 --screen-cast-port 8081 --no-qt")
return
# Test 2: Login
session = test_login()
if not session:
print("\nERROR Cannot proceed without successful login")
return
# Test 3: API endpoint
api_success = test_api_endpoint(session)
# Summary
print("\n" + "=" * 40)
print("SUMMARY:")
print(f"Server Connection: OK")
print(f"Login: OK")
print(f"API Endpoint: {'OK' if api_success else 'ERROR'}")
if api_success:
print("\nSUCCESS The cashier dashboard API is working!")
print("If the web interface is not showing matches, the issue is likely in the JavaScript.")
else:
print("\nERROR The API is not working. Check server logs for errors.")
if __name__ == "__main__":
main()
\ No newline at end of file
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Test script for --dev-message functionality
"""
import sys
import os
from pathlib import Path
# Add the project root to Python path
project_root = Path(__file__).parent
sys.path.insert(0, str(project_root))
from mbetterclient.core.message_bus import MessageBus, Message, MessageType
from mbetterclient.config.settings import AppSettings
def test_dev_message_flag():
"""Test the --dev-message flag functionality"""
print("Testing --dev-message flag functionality")
print("=" * 50)
# Test 1: Normal mode (dev_message=False)
print("\nTest 1: Normal mode (dev_message=False)")
message_bus_normal = MessageBus(dev_message=False)
# Create a test message
test_message = Message(
type=MessageType.START_GAME,
sender="test_component",
data={"fixture_id": "test_123"}
)
print("Publishing message in normal mode...")
message_bus_normal.publish(test_message)
print("Normal mode test completed")
# Test 2: Dev message mode (dev_message=True)
print("\nTest 2: Dev message mode (dev_message=True)")
message_bus_dev = MessageBus(dev_message=True)
print("Publishing message in dev message mode...")
message_bus_dev.publish(test_message)
print("Dev message mode test completed")
# Test 3: Settings integration
print("\nTest 3: Settings integration")
settings = AppSettings()
settings.dev_message = True
message_bus_from_settings = MessageBus(dev_message=settings.dev_message)
print(f"MessageBus created with dev_message={message_bus_from_settings.dev_message}")
print("Settings integration test completed")
print("\nAll tests completed successfully!")
print("\nUsage:")
print(" python main.py --dev-message # Enable dev message mode")
print(" python main.py # Normal mode (default)")
if __name__ == "__main__":
test_dev_message_flag()
\ No newline at end of file
import re
with open('mbetterclient/web_dashboard/templates/dashboard/fixtures.html', 'r') as f:
content = f.read()
# Check for basic HTML structure
if '<!DOCTYPE' in content or content.strip().startswith('<'):
print('HTML template appears to have valid structure')
else:
print('HTML template may have issues')
# Check for JavaScript syntax errors
js_matches = re.findall(r'<script[^>]*>(.*?)</script>', content, re.DOTALL)
if js_matches:
print('Found ' + str(len(js_matches)) + ' script tags')
for i, js in enumerate(js_matches):
try:
compile(js, 'script_' + str(i), 'exec')
print('Script ' + str(i+1) + ': OK')
except SyntaxError as e:
print('Script ' + str(i+1) + ': Syntax error - ' + str(e))
else:
print('No script tags found')
\ No newline at end of file
#!/usr/bin/env python3
"""
Simple test script to verify the timer functionality
"""
import sys
import time
import threading
from pathlib import Path
# Add the project root to Python path
project_root = Path(__file__).parent
sys.path.insert(0, str(project_root))
from mbetterclient.config.settings import AppSettings
from mbetterclient.core.message_bus import MessageBus, MessageType
def test_timer():
"""Test the timer functionality"""
print("Testing timer functionality...")
# Create settings with timer enabled
settings = AppSettings()
settings.timer.enabled = True
settings.timer.delay_minutes = 0.1 # 6 seconds for testing
# Create message bus
message_bus = MessageBus()
# Variables to track messages
start_game_received = [False]
start_game_delayed_received = [False]
def message_handler(message):
if message.type == MessageType.START_GAME:
print(f"✓ START_GAME message received from {message.sender}")
start_game_received[0] = True
# Simulate the application behavior - start timer when START_GAME is received
if settings.timer.enabled:
delay_seconds = settings.timer.delay_minutes * 60
print(f"Starting timer for {delay_seconds} seconds...")
def timer_callback():
print("Timer expired, sending START_GAME_DELAYED message")
from mbetterclient.core.message_bus import MessageBuilder
delayed_message = MessageBuilder.start_game_delayed(
sender="timer",
delay_minutes=settings.timer.delay_minutes
)
message_bus.publish(delayed_message, broadcast=True)
timer = threading.Timer(delay_seconds, timer_callback)
timer.daemon = True
timer.start()
elif message.type == MessageType.START_GAME_DELAYED:
print(f"✓ START_GAME_DELAYED message received from {message.sender}")
start_game_delayed_received[0] = True
# Register handler for both message types
message_bus.subscribe_global(MessageType.START_GAME, message_handler)
message_bus.subscribe_global(MessageType.START_GAME_DELAYED, message_handler)
# Send START_GAME message to trigger the timer
print("Sending START_GAME message to trigger timer...")
from mbetterclient.core.message_bus import MessageBuilder
start_game_message = MessageBuilder.start_game(sender="test_trigger")
message_bus.publish(start_game_message, broadcast=True)
# Wait for the delayed message
delay_seconds = settings.timer.delay_minutes * 60
print(f"Waiting for timer to expire ({delay_seconds} seconds)...")
time.sleep(delay_seconds + 1)
if start_game_received[0] and start_game_delayed_received[0]:
print("✓ Test PASSED: Both START_GAME and START_GAME_DELAYED messages were received")
return True
else:
print(f"✗ Test FAILED: START_GAME received: {start_game_received[0]}, START_GAME_DELAYED received: {start_game_delayed_received[0]}")
return False
if __name__ == "__main__":
success = test_timer()
sys.exit(0 if success else 1)
\ No newline at end of file
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment