feat: Add countdown timer for match intervals

- Add MATCH_START message type to message bus for match status changes
- Implement countdown timer in both user and cashier interfaces
- Create API endpoints for match timer configuration and match starting
- Add timer display to status bar and navbar with visual feedback
- Implement automatic match progression when timer reaches zero
- Add CSS styling with color-coded states and animations
- Timer defaults to configured match interval (20 minutes)
- Automatically finds and starts next available match in priority order
- Stops timer when no matches are available to start

Features:
- Real-time countdown with MM:SS format display
- Color-coded timer states (normal/warning/danger)
- Automatic match status progression via message bus
- Priority-based match selection (bet -> scheduled -> pending)
- Responsive design for both desktop and mobile
- Error handling and graceful fallbacks
- Integration with existing dashboard notification system
parent 2966ca0e
......@@ -60,6 +60,13 @@ class MessageType(Enum):
# Log messages
LOG_ENTRY = "LOG_ENTRY"
# Game messages
START_GAME = "START_GAME"
START_GAMES = "START_GAMES"
MATCH_START = "MATCH_START"
GAME_STATUS = "GAME_STATUS"
GAME_UPDATE = "GAME_UPDATE"
# Custom messages (for future extensions)
CUSTOM = "CUSTOM"
......@@ -530,3 +537,26 @@ class MessageBuilder:
"details": details or {}
}
)
@staticmethod
def start_game(sender: str, fixture_id: Optional[str] = None) -> Message:
"""Create START_GAME message"""
return Message(
type=MessageType.START_GAME,
sender=sender,
data={
"fixture_id": fixture_id
}
)
@staticmethod
def match_start(sender: str, fixture_id: str, match_id: int) -> Message:
"""Create MATCH_START message"""
return Message(
type=MessageType.MATCH_START,
sender=sender,
data={
"fixture_id": fixture_id,
"match_id": match_id
}
)
\ No newline at end of file
......@@ -1608,6 +1608,165 @@ def trigger_api_request():
return jsonify({"error": str(e)}), 500
@api_bp.route('/match-timer/config')
@api_bp.auth_manager.require_auth if hasattr(api_bp, 'auth_manager') and api_bp.auth_manager else login_required
def get_match_timer_config():
"""Get match timer configuration"""
try:
if api_bp.config_manager:
general_config = api_bp.config_manager.get_section_config("general") or {}
match_interval = general_config.get('match_interval', 20) # Default 20 minutes
else:
match_interval = 20 # Default fallback
return jsonify({
"success": True,
"match_interval": match_interval
})
except Exception as e:
logger.error(f"API get match timer config error: {e}")
return jsonify({"error": str(e)}), 500
@api_bp.route('/match-timer/start-match', methods=['POST'])
@api_bp.auth_manager.require_auth if hasattr(api_bp, 'auth_manager') and api_bp.auth_manager else login_required
def start_next_match():
"""Start the next match by sending MATCH_START message"""
try:
from ..database.models import MatchModel
from datetime import datetime, date
from ..core.message_bus import MessageBuilder, MessageType
session = api_bp.db_manager.get_session()
try:
# Get today's date
today = date.today()
# Find the first fixture with matches starting today
fixtures_with_today_start = session.query(MatchModel.fixture_id)\
.filter(MatchModel.start_time.isnot(None))\
.filter(MatchModel.start_time >= datetime.combine(today, datetime.min.time()))\
.filter(MatchModel.start_time < datetime.combine(today, datetime.max.time()))\
.order_by(MatchModel.created_at.asc())\
.first()
selected_fixture_id = None
if fixtures_with_today_start:
selected_fixture_id = fixtures_with_today_start.fixture_id
logger.info(f"Selected fixture {selected_fixture_id} - has matches starting today")
else:
# Fallback: find fixtures where all matches are in pending status
all_fixtures = session.query(MatchModel.fixture_id).distinct().all()
for fixture_row in all_fixtures:
fixture_id = fixture_row.fixture_id
# Check if all matches in this fixture are pending
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):
selected_fixture_id = fixture_id
logger.info(f"Selected fixture {selected_fixture_id} - all matches are pending")
break
# If no fixture with all pending matches found, use the first fixture by creation date
if not selected_fixture_id:
first_fixture = session.query(MatchModel.fixture_id)\
.order_by(MatchModel.created_at.asc())\
.first()
if first_fixture:
selected_fixture_id = first_fixture.fixture_id
logger.info(f"Selected first fixture {selected_fixture_id} - fallback")
if not selected_fixture_id:
return jsonify({
"success": False,
"error": "No suitable fixture found"
}), 404
# Get all matches from the selected fixture
fixture_matches = session.query(MatchModel)\
.filter_by(fixture_id=selected_fixture_id)\
.order_by(MatchModel.match_number.asc())\
.all()
if not fixture_matches:
return jsonify({
"success": False,
"error": "No matches found in fixture"
}), 404
# Find the first match that needs to be started
target_match = None
# Priority 1: First match in "bet" status
for match in fixture_matches:
if match.status == 'bet':
target_match = match
break
# Priority 2: First match in "scheduled" status
if not target_match:
for match in fixture_matches:
if match.status == 'scheduled':
target_match = match
break
# Priority 3: First match in "pending" status
if not target_match:
for match in fixture_matches:
if match.status == 'pending':
target_match = match
break
if not target_match:
return jsonify({
"success": False,
"error": "No match available to start"
}), 404
# Send MATCH_START message to message bus
if api_bp.message_bus:
match_start_message = MessageBuilder.match_start(
sender="web_dashboard",
fixture_id=selected_fixture_id,
match_id=target_match.id
)
success = api_bp.message_bus.publish(match_start_message)
if success:
logger.info(f"MATCH_START message sent for match {target_match.id} in fixture {selected_fixture_id}")
return jsonify({
"success": True,
"message": f"Match {target_match.match_number} started",
"fixture_id": selected_fixture_id,
"match_id": target_match.id,
"match_number": target_match.match_number
})
else:
logger.error("Failed to publish MATCH_START message to message bus")
return jsonify({
"success": False,
"error": "Failed to send match start request"
}), 500
else:
logger.error("Message bus not available")
return jsonify({
"success": False,
"error": "Message bus not available"
}), 500
finally:
session.close()
except Exception as e:
logger.error(f"API start next match error: {e}")
return jsonify({"error": str(e)}), 500
@api_bp.route('/server-time')
def get_server_time():
"""Get current server time"""
......
......@@ -146,6 +146,73 @@ body {
backdrop-filter: blur(10px);
}
/* Match Timer Styles */
#match-timer {
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-weight: bold;
font-size: 0.875rem;
min-width: 60px;
text-align: center;
transition: all 0.3s ease;
}
#match-timer-display {
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-weight: bold;
font-size: 1rem;
min-width: 60px;
text-align: center;
transition: all 0.3s ease;
}
/* Navbar timer styling */
.navbar-timer {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 0.5rem;
padding: 0.375rem 0.75rem;
color: white;
font-size: 0.875rem;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.navbar-timer i {
color: rgba(255,255,255,0.8);
}
/* Timer animations */
@keyframes timerPulse {
0% { transform: scale(1); }
50% { transform: scale(1.05); }
100% { transform: scale(1); }
}
#match-timer.timer-low {
animation: timerPulse 1s infinite;
}
#match-timer-display.timer-low {
animation: timerPulse 1s infinite;
}
/* Timer color states */
#match-timer.bg-danger {
background: linear-gradient(135deg, #dc3545 0%, #b02a37 100%) !important;
animation: timerPulse 0.5s infinite;
}
#match-timer.bg-warning {
background: linear-gradient(135deg, #ffc107 0%, #e0a800 100%) !important;
}
#match-timer-display.text-danger {
color: #dc3545 !important;
animation: timerPulse 0.5s infinite;
}
#match-timer-display.text-warning {
color: #ffc107 !important;
}
/* Offline indicator */
#offline-indicator {
z-index: 1050;
......
......@@ -33,6 +33,9 @@ window.Dashboard = (function() {
// Load cached data
loadFromCache();
// Initialize match timer
initMatchTimer();
console.log('Dashboard initialized successfully');
}
......@@ -447,6 +450,141 @@ window.Dashboard = (function() {
});
}
// Match Timer functionality
let matchTimerInterval = null;
let matchTimerSeconds = 0;
let matchTimerRunning = false;
function initMatchTimer() {
// Get match interval configuration
apiRequest('GET', '/match-timer/config')
.then(function(data) {
if (data.success && data.match_interval) {
matchTimerSeconds = data.match_interval * 60; // Convert minutes to seconds
startMatchTimer();
} else {
console.error('Failed to get match timer config:', data);
// Fallback to 20 minutes
matchTimerSeconds = 20 * 60;
startMatchTimer();
}
})
.catch(function(error) {
console.error('Failed to initialize match timer:', error);
// Fallback to 20 minutes
matchTimerSeconds = 20 * 60;
startMatchTimer();
});
}
function startMatchTimer() {
if (matchTimerInterval) {
clearInterval(matchTimerInterval);
}
matchTimerRunning = true;
updateMatchTimerDisplay();
matchTimerInterval = setInterval(function() {
if (matchTimerSeconds > 0) {
matchTimerSeconds--;
updateMatchTimerDisplay();
} else {
// Timer reached 0, start next match
startNextMatch();
}
}, 1000);
}
function stopMatchTimer() {
matchTimerRunning = false;
if (matchTimerInterval) {
clearInterval(matchTimerInterval);
matchTimerInterval = null;
}
}
function resetMatchTimer(seconds) {
matchTimerSeconds = seconds || (20 * 60); // Default to 20 minutes
if (!matchTimerRunning) {
startMatchTimer();
}
updateMatchTimerDisplay();
}
function updateMatchTimerDisplay() {
const minutes = Math.floor(matchTimerSeconds / 60);
const seconds = matchTimerSeconds % 60;
const timeString = minutes.toString().padStart(2, '0') + ':' + seconds.toString().padStart(2, '0');
// Update status bar timer
const statusTimer = document.getElementById('match-timer');
if (statusTimer) {
statusTimer.textContent = timeString;
// Change color when timer is low
if (matchTimerSeconds <= 60) { // Last minute
statusTimer.className = 'badge bg-danger text-white';
} else if (matchTimerSeconds <= 300) { // Last 5 minutes
statusTimer.className = 'badge bg-warning text-dark';
} else {
statusTimer.className = 'badge bg-warning text-dark';
}
}
// Update navbar timer (for cashier dashboard)
const navbarTimer = document.getElementById('match-timer-display');
if (navbarTimer) {
navbarTimer.textContent = timeString;
navbarTimer.className = matchTimerSeconds <= 60 ? 'text-danger fw-bold' : 'text-warning fw-bold';
}
}
function startNextMatch() {
console.log('Match timer reached 0, starting next match...');
apiRequest('POST', '/match-timer/start-match')
.then(function(data) {
if (data.success) {
console.log('Match started successfully:', data);
showNotification('Match ' + data.match_number + ' started successfully', 'success');
// 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
resetMatchTimer(20 * 60);
});
}
// Utility functions
function formatBytes(bytes, decimals) {
if (bytes === 0) return '0 Bytes';
......@@ -471,7 +609,13 @@ window.Dashboard = (function() {
formatBytes: formatBytes,
formatTimestamp: formatTimestamp,
isOnline: function() { return isOnline; },
getConfig: function() { return config; }
getConfig: function() { return config; },
// Match timer functions
initMatchTimer: initMatchTimer,
startMatchTimer: startMatchTimer,
stopMatchTimer: stopMatchTimer,
resetMatchTimer: resetMatchTimer,
startNextMatch: startNextMatch
};
})();
......
......@@ -204,6 +204,10 @@
<span class="text-muted">Video:</span>
<span id="video-status" class="badge bg-secondary">Stopped</span>
</div>
<div class="col-auto">
<span class="text-muted">Match Timer:</span>
<span id="match-timer" class="badge bg-warning text-dark">--:--</span>
</div>
<div class="col-auto">
<span class="text-muted">Last Updated:</span>
<span id="last-updated" class="text-muted">--</span>
......
......@@ -72,6 +72,13 @@
<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>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ url_for('auth.logout') }}">
<i class="fas fa-sign-out-alt me-1"></i>Logout
......
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