feat: Implement real-time notifications with long polling

- Add /api/notifications endpoint for long polling real-time updates
- Implement JavaScript long polling in base template for all dashboards
- Add START_GAME, MATCH_START, and GAME_STATUS message handling
- Update cashier dashboard with real-time timer and status updates
- Handle match status updates when messages are received
- Add visual notifications with toast messages
- Implement automatic reconnection with exponential backoff
- Update match timer display and pending matches list in real-time
- Add event-driven architecture for dashboard updates

Features:
- Long polling requests every 30 seconds for real-time updates
- START_GAME message starts countdown timer and updates status
- MATCH_START message resets timer and shows running state
- GAME_STATUS message updates match status and refreshes data
- Automatic UI updates without page refresh
- Toast notifications for important events
- Robust error handling and reconnection logic
parent 74557512
......@@ -1786,6 +1786,71 @@ def get_server_time():
return jsonify({"error": str(e)}), 500
@api_bp.route('/notifications')
@api_bp.auth_manager.require_auth if hasattr(api_bp, 'auth_manager') and api_bp.auth_manager else login_required
def notifications():
"""Long polling endpoint for real-time notifications"""
try:
import time
import threading
from ..core.message_bus import MessageType
# Get timeout from query parameter (default 30 seconds)
timeout = int(request.args.get('timeout', 30))
if timeout > 60: # Max 60 seconds
timeout = 60
# Create a queue for this client
notification_queue = []
notification_received = threading.Event()
def message_handler(message):
"""Handle incoming messages for this client"""
if message.type in [MessageType.START_GAME, MessageType.MATCH_START, MessageType.GAME_STATUS]:
notification_data = {
"type": message.type.value,
"data": message.data,
"timestamp": message.timestamp,
"sender": message.sender
}
notification_queue.append(notification_data)
notification_received.set()
# Subscribe to relevant message types
if api_bp.message_bus:
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.GAME_STATUS, message_handler)
# Wait for notification or timeout
notification_received.wait(timeout=timeout)
# Unsubscribe from messages
if api_bp.message_bus:
api_bp.message_bus._global_handlers[MessageType.START_GAME].remove(message_handler)
api_bp.message_bus._global_handlers[MessageType.MATCH_START].remove(message_handler)
api_bp.message_bus._global_handlers[MessageType.GAME_STATUS].remove(message_handler)
if notification_queue:
# Return the first notification received
notification = notification_queue[0]
logger.info(f"Notification sent to client: {notification['type']}")
return jsonify({
"success": True,
"notification": notification
})
else:
# Timeout - return empty response
return jsonify({
"success": True,
"notification": None
})
except Exception as e:
logger.error(f"Notifications API error: {e}")
return jsonify({"error": str(e)}), 500
# Extraction API routes
@api_bp.route('/extraction/outcomes')
@api_bp.auth_manager.require_auth if hasattr(api_bp, 'auth_manager') and api_bp.auth_manager else login_required
......
......@@ -102,6 +102,12 @@
<i class="fas fa-cogs me-1"></i>Extraction
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if request.endpoint == 'main.extraction' %}active{% endif %}"
href="{{ url_for('main.extraction') }}">
<i class="fas fa-cogs me-1"></i>Extraction
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if request.endpoint == 'main.video_test' %}active{% endif %}"
href="{{ url_for('main.video_test') }}">
......@@ -246,6 +252,9 @@
// Initialize digital clock
initializeClock();
// Initialize long polling for notifications
initializeNotifications();
});
function initializeClock() {
......@@ -300,6 +309,185 @@
setInterval(fetchServerTime, 30000);
});
}
function initializeNotifications() {
let pollingActive = true;
let reconnectDelay = 1000; // Start with 1 second delay
const maxReconnectDelay = 30000; // Max 30 seconds
function pollNotifications() {
if (!pollingActive) return;
fetch('/api/notifications?timeout=30', {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
credentials: 'same-origin'
})
.then(response => {
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return response.json();
})
.then(data => {
if (data.success && data.notification) {
handleNotification(data.notification);
}
// Reset reconnect delay on successful response
reconnectDelay = 1000;
// Continue polling
setTimeout(pollNotifications, 100);
})
.catch(error => {
console.error('Notification polling error:', error);
// Exponential backoff for reconnection
reconnectDelay = Math.min(reconnectDelay * 2, maxReconnectDelay);
console.log(`Reconnecting in ${reconnectDelay}ms...`);
setTimeout(pollNotifications, reconnectDelay);
});
}
function handleNotification(notification) {
console.log('Received notification:', notification);
const { type, data, timestamp } = notification;
// Handle different notification types
switch (type) {
case 'START_GAME':
handleStartGame(data, timestamp);
break;
case 'MATCH_START':
handleMatchStart(data, timestamp);
break;
case 'GAME_STATUS':
handleGameStatus(data, timestamp);
break;
default:
console.log('Unknown notification type:', type);
}
// Show notification to user
showNotificationToast(type, data);
}
function handleStartGame(data, timestamp) {
console.log('Handling START_GAME notification:', data);
// Update match timer display
const matchTimerElement = document.getElementById('match-timer');
if (matchTimerElement) {
matchTimerElement.textContent = 'Starting...';
matchTimerElement.className = 'badge bg-success';
}
// Trigger custom event for page-specific handling
const event = new CustomEvent('startGame', {
detail: { data, timestamp }
});
document.dispatchEvent(event);
}
function handleMatchStart(data, timestamp) {
console.log('Handling MATCH_START notification:', data);
// Reset and restart timer
const matchTimerElement = document.getElementById('match-timer');
if (matchTimerElement) {
matchTimerElement.textContent = '00:00';
matchTimerElement.className = 'badge bg-primary';
}
// Trigger custom event for page-specific handling
const event = new CustomEvent('matchStart', {
detail: { data, timestamp }
});
document.dispatchEvent(event);
}
function handleGameStatus(data, timestamp) {
console.log('Handling GAME_STATUS notification:', data);
// Update status displays
const status = data.status;
const fixtureId = data.fixture_id;
// Update status bar if present
const systemStatusElement = document.getElementById('system-status');
if (systemStatusElement) {
systemStatusElement.textContent = status;
systemStatusElement.className = `badge bg-${getStatusColor(status)}`;
}
// Trigger custom event for page-specific handling
const event = new CustomEvent('gameStatus', {
detail: { data, timestamp }
});
document.dispatchEvent(event);
}
function showNotificationToast(type, data) {
// Create and show a toast notification
const toastHtml = `
<div class="toast align-items-center text-white bg-primary border-0" role="alert">
<div class="d-flex">
<div class="toast-body">
<i class="fas fa-bell me-2"></i>
<strong>${type.replace('_', ' ')}</strong>
${data.fixture_id ? ` - Fixture ${data.fixture_id}` : ''}
</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
</div>
</div>
`;
// Add toast to container or create one
let toastContainer = document.querySelector('.toast-container');
if (!toastContainer) {
toastContainer = document.createElement('div');
toastContainer.className = 'toast-container position-fixed top-0 end-0 p-3';
toastContainer.style.zIndex = '9999';
document.body.appendChild(toastContainer);
}
toastContainer.insertAdjacentHTML('beforeend', toastHtml);
// Initialize and show the toast
const toastElement = toastContainer.lastElementChild;
const toast = new bootstrap.Toast(toastElement, {
autohide: true,
delay: 5000
});
toast.show();
// Remove from DOM after hiding
toastElement.addEventListener('hidden.bs.toast', () => {
toastElement.remove();
});
}
function getStatusColor(status) {
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';
}
}
// Start polling
console.log('Starting notification polling...');
pollNotifications();
// Stop polling when page unloads
window.addEventListener('beforeunload', () => {
pollingActive = false;
});
}
</script>
{% endif %}
......
......@@ -391,9 +391,12 @@
// Initialize digital clock
initializeClock();
// Initialize long polling for notifications
initializeNotifications();
// Load pending matches for cashier dashboard
loadPendingMatches();
// Betting mode functionality removed for cashier users
});
......@@ -555,6 +558,84 @@
`;
});
}
// Add event listeners for real-time notifications
document.addEventListener('startGame', function(event) {
console.log('Cashier dashboard: Received startGame event', event.detail);
// Update match timer display
const matchTimerElement = document.getElementById('match-timer-display');
if (matchTimerElement) {
matchTimerElement.textContent = 'Starting...';
}
// Refresh pending matches to show updated status
loadPendingMatches();
// Show notification to cashier
showNotification('Games are starting! Match timer activated.', 'success');
});
document.addEventListener('matchStart', function(event) {
console.log('Cashier dashboard: Received matchStart event', event.detail);
// Reset and start the match timer
const matchTimerElement = document.getElementById('match-timer-display');
if (matchTimerElement) {
matchTimerElement.textContent = '00:00';
// Start counting up from 00:00
startMatchTimer();
}
// Refresh pending matches to show updated status
loadPendingMatches();
// Show notification to cashier
showNotification('Match started! Timer is now running.', 'info');
});
document.addEventListener('gameStatus', function(event) {
console.log('Cashier dashboard: Received gameStatus event', event.detail);
// Refresh pending matches to show updated status
loadPendingMatches();
// Update system status if present
const systemStatusElement = document.getElementById('system-status');
if (systemStatusElement && event.detail.data.status) {
systemStatusElement.textContent = event.detail.data.status;
systemStatusElement.className = `badge bg-${getStatusColor(event.detail.data.status)}`;
}
});
function startMatchTimer() {
let seconds = 0;
const matchTimerElement = document.getElementById('match-timer-display');
if (!matchTimerElement) return;
const timer = setInterval(() => {
seconds++;
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
const timeString = `${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`;
matchTimerElement.textContent = timeString;
}, 1000);
// Store timer reference for cleanup if needed
matchTimerElement.dataset.timerId = timer;
}
function getStatusColor(status) {
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>
......
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