Compare commits

..

2 Commits

Author SHA1 Message Date
James Pattinson
86f1dc65f4 Public display improvements WIP 2025-12-10 11:25:33 +00:00
James Pattinson
169c3af29b Date issues 2025-12-10 10:45:34 +00:00
3 changed files with 290 additions and 64 deletions

View File

@@ -55,7 +55,8 @@ class CRUDPPR:
func.date(PPRRecord.eta) == today, func.date(PPRRecord.eta) == today,
or_( or_(
PPRRecord.status == PPRStatus.NEW, PPRRecord.status == PPRStatus.NEW,
PPRRecord.status == PPRStatus.CONFIRMED PPRRecord.status == PPRStatus.CONFIRMED,
PPRRecord.status == PPRStatus.LANDED
) )
) )
).order_by(PPRRecord.eta).all() ).order_by(PPRRecord.eta).all()
@@ -66,7 +67,10 @@ class CRUDPPR:
return db.query(PPRRecord).filter( return db.query(PPRRecord).filter(
and_( and_(
func.date(PPRRecord.etd) == today, func.date(PPRRecord.etd) == today,
PPRRecord.status == PPRStatus.LANDED or_(
PPRRecord.status == PPRStatus.LANDED,
PPRRecord.status == PPRStatus.DEPARTED
)
) )
).order_by(PPRRecord.etd).all() ).order_by(PPRRecord.etd).all()

View File

@@ -25,6 +25,8 @@
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
box-shadow: 0 2px 10px rgba(0,0,0,0.1); box-shadow: 0 2px 10px rgba(0,0,0,0.1);
position: relative;
z-index: 100;
} }
.title h1 { .title h1 {
@@ -541,11 +543,13 @@
transform: translateY(-20px); transform: translateY(-20px);
transition: all 0.3s ease; transition: all 0.3s ease;
font-weight: 500; font-weight: 500;
pointer-events: none;
} }
.notification.show { .notification.show {
opacity: 1; opacity: 1;
transform: translateY(0); transform: translateY(0);
pointer-events: auto;
} }
.notification.error { .notification.error {
@@ -927,6 +931,12 @@
let isNewPPR = false; let isNewPPR = false;
let wsConnection = null; let wsConnection = null;
let pendingStatusUpdate = null; // Track pending status update for timestamp modal let pendingStatusUpdate = null; // Track pending status update for timestamp modal
let wsHeartbeatInterval = null;
let wsReconnectTimeout = null;
let lastHeartbeatResponse = null;
let sessionExpiryWarningShown = false;
let sessionExpiryCheckInterval = null;
let etdManuallyEdited = false; // Track if user has manually edited ETD
// WebSocket connection for real-time updates // WebSocket connection for real-time updates
function connectWebSocket() { function connectWebSocket() {
@@ -934,17 +944,34 @@
return; // Already connected return; // Already connected
} }
// Clear any existing reconnect timeout
if (wsReconnectTimeout) {
clearTimeout(wsReconnectTimeout);
wsReconnectTimeout = null;
}
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/ws/tower-updates`; const wsUrl = `${protocol}//${window.location.host}/ws/tower-updates`;
console.log('Connecting to WebSocket:', wsUrl);
wsConnection = new WebSocket(wsUrl); wsConnection = new WebSocket(wsUrl);
wsConnection.onopen = function(event) { wsConnection.onopen = function(event) {
console.log('WebSocket connected for real-time updates'); console.log('WebSocket connected for real-time updates');
lastHeartbeatResponse = Date.now();
startHeartbeat();
showNotification('Real-time updates connected');
}; };
wsConnection.onmessage = function(event) { wsConnection.onmessage = function(event) {
try { try {
// Check if it's a heartbeat response
if (event.data.startsWith('Heartbeat:')) {
lastHeartbeatResponse = Date.now();
console.log('💓 WebSocket heartbeat received');
return;
}
const data = JSON.parse(event.data); const data = JSON.parse(event.data);
console.log('WebSocket message received:', data); console.log('WebSocket message received:', data);
@@ -952,6 +979,7 @@
if (data.type && (data.type.includes('ppr_') || data.type === 'status_update')) { if (data.type && (data.type.includes('ppr_') || data.type === 'status_update')) {
console.log('PPR update detected, refreshing...'); console.log('PPR update detected, refreshing...');
loadPPRs(); loadPPRs();
showNotification('Data updated');
} }
} catch (error) { } catch (error) {
console.error('Error parsing WebSocket message:', error); console.error('Error parsing WebSocket message:', error);
@@ -959,22 +987,59 @@
}; };
wsConnection.onclose = function(event) { wsConnection.onclose = function(event) {
console.log('WebSocket disconnected'); console.log('⚠️ WebSocket disconnected', event.code, event.reason);
// Attempt to reconnect after 5 seconds stopHeartbeat();
setTimeout(() => {
if (accessToken) { // Only reconnect if still logged in // Attempt to reconnect after 5 seconds if still logged in
if (accessToken) {
showNotification('Real-time updates disconnected, reconnecting...', true);
wsReconnectTimeout = setTimeout(() => {
console.log('Attempting to reconnect WebSocket...'); console.log('Attempting to reconnect WebSocket...');
connectWebSocket(); connectWebSocket();
} }, 5000);
}, 5000); }
}; };
wsConnection.onerror = function(error) { wsConnection.onerror = function(error) {
console.error('WebSocket error:', error); console.error('WebSocket error:', error);
}; };
} }
function startHeartbeat() {
// Clear any existing heartbeat
stopHeartbeat();
// Send ping every 30 seconds
wsHeartbeatInterval = setInterval(() => {
if (wsConnection && wsConnection.readyState === WebSocket.OPEN) {
wsConnection.send('ping');
console.log('💓 Sending WebSocket heartbeat');
// Check if last heartbeat was more than 60 seconds ago
if (lastHeartbeatResponse && (Date.now() - lastHeartbeatResponse > 60000)) {
console.warn('⚠️ No heartbeat response for 60 seconds, reconnecting...');
wsConnection.close();
}
} else {
console.warn('⚠️ WebSocket not open, stopping heartbeat');
stopHeartbeat();
}
}, 30000);
}
function stopHeartbeat() {
if (wsHeartbeatInterval) {
clearInterval(wsHeartbeatInterval);
wsHeartbeatInterval = null;
}
}
function disconnectWebSocket() { function disconnectWebSocket() {
stopHeartbeat();
if (wsReconnectTimeout) {
clearTimeout(wsReconnectTimeout);
wsReconnectTimeout = null;
}
if (wsConnection) { if (wsConnection) {
wsConnection.close(); wsConnection.close();
wsConnection = null; wsConnection = null;
@@ -1020,6 +1085,52 @@
}); });
} }
// Session expiry monitoring
function startSessionExpiryCheck() {
// Clear any existing interval
if (sessionExpiryCheckInterval) {
clearInterval(sessionExpiryCheckInterval);
}
sessionExpiryWarningShown = false;
// Check every 60 seconds
sessionExpiryCheckInterval = setInterval(() => {
const tokenExpiry = localStorage.getItem('ppr_token_expiry');
if (!tokenExpiry || !accessToken) {
stopSessionExpiryCheck();
return;
}
const now = Date.now();
const expiryTime = parseInt(tokenExpiry);
const timeUntilExpiry = expiryTime - now;
// Warn if less than 5 minutes remaining
if (timeUntilExpiry < 5 * 60 * 1000 && !sessionExpiryWarningShown) {
sessionExpiryWarningShown = true;
const minutesLeft = Math.ceil(timeUntilExpiry / 60000);
showNotification(`⚠️ Session expires in ${minutesLeft} minute${minutesLeft !== 1 ? 's' : ''}. Please save your work.`, true);
console.warn(`Session expires in ${minutesLeft} minutes`);
}
// Force logout if expired
if (timeUntilExpiry <= 0) {
console.error('Session has expired');
showNotification('Session expired. Please log in again.', true);
logout();
}
}, 60000); // Check every minute
}
function stopSessionExpiryCheck() {
if (sessionExpiryCheckInterval) {
clearInterval(sessionExpiryCheckInterval);
sessionExpiryCheckInterval = null;
}
sessionExpiryWarningShown = false;
}
// Authentication management // Authentication management
async function initializeAuth() { async function initializeAuth() {
// Try to get cached token // Try to get cached token
@@ -1035,6 +1146,7 @@
currentUser = cachedUser; currentUser = cachedUser;
document.getElementById('current-user').textContent = cachedUser; document.getElementById('current-user').textContent = cachedUser;
await updateUserRole(); // Update role-based UI await updateUserRole(); // Update role-based UI
startSessionExpiryCheck(); // Start monitoring session expiry
connectWebSocket(); // Connect WebSocket for real-time updates connectWebSocket(); // Connect WebSocket for real-time updates
loadPPRs(); loadPPRs();
return; return;
@@ -1128,6 +1240,7 @@
hideLogin(); hideLogin();
await updateUserRole(); // Update role-based UI await updateUserRole(); // Update role-based UI
startSessionExpiryCheck(); // Start monitoring session expiry
connectWebSocket(); // Connect WebSocket for real-time updates connectWebSocket(); // Connect WebSocket for real-time updates
loadPPRs(); loadPPRs();
} else { } else {
@@ -1153,6 +1266,7 @@
accessToken = null; accessToken = null;
currentUser = null; currentUser = null;
stopSessionExpiryCheck(); // Stop monitoring session
disconnectWebSocket(); // Disconnect WebSocket disconnectWebSocket(); // Disconnect WebSocket
// Close any open modals // Close any open modals
@@ -1298,6 +1412,13 @@
document.getElementById('arrivals-no-data').style.display = 'block'; document.getElementById('arrivals-no-data').style.display = 'block';
return; return;
} }
// Sort arrivals by ETA (ascending)
arrivals.sort((a, b) => {
if (!a.eta) return 1;
if (!b.eta) return -1;
return new Date(a.eta) - new Date(b.eta);
});
tbody.innerHTML = ''; tbody.innerHTML = '';
document.getElementById('arrivals-table-content').style.display = 'block'; document.getElementById('arrivals-table-content').style.display = 'block';
for (const ppr of arrivals) { for (const ppr of arrivals) {
@@ -1346,6 +1467,13 @@
document.getElementById('departures-no-data').style.display = 'block'; document.getElementById('departures-no-data').style.display = 'block';
return; return;
} }
// Sort departures by ETD (ascending), nulls last
departures.sort((a, b) => {
if (!a.etd) return 1;
if (!b.etd) return -1;
return new Date(a.etd) - new Date(b.etd);
});
tbody.innerHTML = ''; tbody.innerHTML = '';
document.getElementById('departures-table-content').style.display = 'block'; document.getElementById('departures-table-content').style.display = 'block';
for (const ppr of departures) { for (const ppr of departures) {
@@ -1419,6 +1547,7 @@
function openNewPPRModal() { function openNewPPRModal() {
isNewPPR = true; isNewPPR = true;
currentPPRId = null; currentPPRId = null;
etdManuallyEdited = false; // Reset the manual edit flag for new PPR
document.getElementById('modal-title').textContent = 'New PPR Entry'; document.getElementById('modal-title').textContent = 'New PPR Entry';
document.getElementById('delete-btn').style.display = 'none'; document.getElementById('delete-btn').style.display = 'none';
document.getElementById('journal-section').style.display = 'none'; document.getElementById('journal-section').style.display = 'none';
@@ -1465,6 +1594,38 @@
}, 100); }, 100);
} }
// Function to update ETD based on ETA (2 hours later)
function updateETDFromETA() {
// Only auto-update if user hasn't manually edited ETD
if (etdManuallyEdited) {
return;
}
const etaDate = document.getElementById('eta-date').value;
const etaTime = document.getElementById('eta-time').value;
if (etaDate && etaTime) {
// Parse ETA
const eta = new Date(`${etaDate}T${etaTime}`);
// Calculate ETD (2 hours after ETA)
const etd = new Date(eta.getTime() + 2 * 60 * 60 * 1000);
// Format ETD
const etdDateStr = `${etd.getFullYear()}-${String(etd.getMonth() + 1).padStart(2, '0')}-${String(etd.getDate()).padStart(2, '0')}`;
const etdTimeStr = `${String(etd.getHours()).padStart(2, '0')}:${String(etd.getMinutes()).padStart(2, '0')}`;
// Update ETD fields
document.getElementById('etd-date').value = etdDateStr;
document.getElementById('etd-time').value = etdTimeStr;
}
}
// Function to mark ETD as manually edited
function markETDAsManuallyEdited() {
etdManuallyEdited = true;
}
async function openPPRModal(pprId) { async function openPPRModal(pprId) {
if (!accessToken) return; if (!accessToken) return;
@@ -2386,6 +2547,14 @@
setupKeyboardShortcuts(); setupKeyboardShortcuts();
initializeTimeDropdowns(); // Initialize time dropdowns initializeTimeDropdowns(); // Initialize time dropdowns
initializeAuth(); // Start authentication process initializeAuth(); // Start authentication process
// Add event listeners to ETA fields to auto-update ETD
document.getElementById('eta-date').addEventListener('change', updateETDFromETA);
document.getElementById('eta-time').addEventListener('change', updateETDFromETA);
// Add event listeners to ETD fields to mark as manually edited
document.getElementById('etd-date').addEventListener('change', markETDAsManuallyEdited);
document.getElementById('etd-time').addEventListener('change', markETDAsManuallyEdited);
}); });
</script> </script>
</body> </body>

View File

@@ -62,6 +62,9 @@
padding: 12px; padding: 12px;
text-align: left; text-align: left;
border: 1px solid #ccc; border: 1px solid #ccc;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
} }
th { th {
@@ -134,7 +137,7 @@
<body> <body>
<header> <header>
<img src="assets/logo.png" alt="EGFH Logo" class="left-image"> <img src="assets/logo.png" alt="EGFH Logo" class="left-image">
<h1>Arrivals/Departures Information</h1> <h1>Flight Information</h1>
<img src="assets/flightImg.png" alt="EGFH Logo" class="right-image"> <img src="assets/flightImg.png" alt="EGFH Logo" class="right-image">
</header> </header>
@@ -145,15 +148,14 @@
<table> <table>
<thead> <thead>
<tr> <tr>
<th>Registration</th> <th style="width: 25%;">Aircraft</th>
<th>Aircraft Type</th> <th style="width: 60%;">From</th>
<th>From</th> <th style="width: 15%;">Due</th>
<th>Due</th>
</tr> </tr>
</thead> </thead>
<tbody id="arrivals-tbody"> <tbody id="arrivals-tbody">
<tr> <tr>
<td colspan="4" class="loading">Loading arrivals...</td> <td colspan="3" class="loading">Loading arrivals...</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@@ -165,15 +167,14 @@
<table> <table>
<thead> <thead>
<tr> <tr>
<th>Registration</th> <th style="width: 25%;">Aircraft</th>
<th>Aircraft Type</th> <th style="width: 60%;">To</th>
<th>To</th> <th style="width: 15%;">Due</th>
<th>Due</th>
</tr> </tr>
</thead> </thead>
<tbody id="departures-tbody"> <tbody id="departures-tbody">
<tr> <tr>
<td colspan="4" class="loading">Loading departures...</td> <td colspan="3" class="loading">Loading departures...</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@@ -192,6 +193,27 @@
<script> <script>
let wsConnection = null; let wsConnection = null;
// ICAO code to airport name cache
const airportNameCache = {};
async function getAirportName(code) {
if (!code || code.length !== 4 || !/^[A-Z]{4}$/.test(code)) return code;
if (airportNameCache[code]) return airportNameCache[code];
try {
const resp = await fetch(`/api/v1/airport/public/lookup/${code}`);
if (resp.ok) {
const data = await resp.json();
if (data && data.length && data[0].name) {
airportNameCache[code] = data[0].name;
return data[0].name;
}
}
} catch (error) {
console.error('Error looking up airport:', error);
}
return code;
}
// WebSocket connection for real-time updates // WebSocket connection for real-time updates
function connectWebSocket() { function connectWebSocket() {
if (wsConnection && wsConnection.readyState === WebSocket.OPEN) { if (wsConnection && wsConnection.readyState === WebSocket.OPEN) {
@@ -237,30 +259,29 @@
}; };
} }
// Convert UTC time to local time (Europe/London) // Convert UTC datetime to local time display
function convertToLocalTime(utcTimeString) { function convertToLocalTime(utcDateTimeString) {
if (!utcTimeString) return ''; if (!utcDateTimeString) return '';
// Parse the time string (format: HH:MM or HH:MM:SS) try {
const timeParts = utcTimeString.split(':'); // Parse the ISO datetime string
if (timeParts.length < 2) return utcTimeString; const date = new Date(utcDateTimeString);
// Create a date object with today's date and the UTC time // Check if valid date
const now = new Date(); if (isNaN(date.getTime())) {
const utcDate = new Date(Date.UTC( console.error('Invalid date:', utcDateTimeString);
now.getUTCFullYear(), return utcDateTimeString;
now.getUTCMonth(), }
now.getUTCDate(),
parseInt(timeParts[0]),
parseInt(timeParts[1]),
timeParts.length > 2 ? parseInt(timeParts[2]) : 0
));
// Convert to local time // Format as HH:MM in local time
const localHours = utcDate.getHours().toString().padStart(2, '0'); const localHours = date.getHours().toString().padStart(2, '0');
const localMinutes = utcDate.getMinutes().toString().padStart(2, '0'); const localMinutes = date.getMinutes().toString().padStart(2, '0');
return `${localHours}:${localMinutes}`; return `${localHours}:${localMinutes}`;
} catch (error) {
console.error('Error converting time:', error, utcDateTimeString);
return utcDateTimeString;
}
} }
// Fetch and display arrivals // Fetch and display arrivals
@@ -277,22 +298,38 @@
const arrivals = await response.json(); const arrivals = await response.json();
if (arrivals.length === 0) { if (arrivals.length === 0) {
tbody.innerHTML = '<tr><td colspan="4">No arrivals found.</td></tr>'; tbody.innerHTML = '<tr><td colspan="3">No arrivals found.</td></tr>';
return; return;
} }
tbody.innerHTML = arrivals.map(arrival => ` // Build rows asynchronously to lookup airport names
<tr> const rows = await Promise.all(arrivals.map(async (arrival) => {
<td>${escapeHtml(arrival.ac_reg || '')}</td> const aircraftDisplay = `${escapeHtml(arrival.ac_reg || '')} <span style="font-size: 0.8em; color: #666;">(${escapeHtml(arrival.ac_type || '')})</span>`;
<td>${escapeHtml(arrival.ac_type || '')}</td> const fromDisplay = await getAirportName(arrival.in_from || '');
<td>${escapeHtml(arrival.in_from || '')}</td>
<td>${convertToLocalTime(arrival.eta)}</td> // Show landed time if available, otherwise ETA
</tr> let timeDisplay;
`).join(''); if (arrival.status === 'LANDED' && arrival.landed_dt) {
const time = convertToLocalTime(arrival.landed_dt);
timeDisplay = `<div style="display: flex; align-items: center; gap: 8px;"><span style="color: #27ae60; font-weight: bold;">${time}</span><span style="font-size: 0.7em; background: #27ae60; color: white; padding: 2px 4px; border-radius: 3px; white-space: nowrap;">LANDED</span></div>`;
} else {
timeDisplay = convertToLocalTime(arrival.eta);
}
return `
<tr>
<td>${aircraftDisplay}</td>
<td>${escapeHtml(fromDisplay)}</td>
<td>${timeDisplay}</td>
</tr>
`;
}));
tbody.innerHTML = rows.join('');
} catch (error) { } catch (error) {
console.error('Error loading arrivals:', error); console.error('Error loading arrivals:', error);
tbody.innerHTML = '<tr><td colspan="4" class="error">Error loading arrivals</td></tr>'; tbody.innerHTML = '<tr><td colspan="3" class="error">Error loading arrivals</td></tr>';
} }
} }
@@ -310,22 +347,38 @@
const departures = await response.json(); const departures = await response.json();
if (departures.length === 0) { if (departures.length === 0) {
tbody.innerHTML = '<tr><td colspan="4">No departures found.</td></tr>'; tbody.innerHTML = '<tr><td colspan="3">No departures found.</td></tr>';
return; return;
} }
tbody.innerHTML = departures.map(departure => ` // Build rows asynchronously to lookup airport names
<tr> const rows = await Promise.all(departures.map(async (departure) => {
<td>${escapeHtml(departure.ac_reg || '')}</td> const aircraftDisplay = `${escapeHtml(departure.ac_reg || '')} <span style="font-size: 0.8em; color: #666;">(${escapeHtml(departure.ac_type || '')})</span>`;
<td>${escapeHtml(departure.ac_type || '')}</td> const toDisplay = await getAirportName(departure.out_to || '');
<td>${escapeHtml(departure.out_to || '')}</td>
<td>${convertToLocalTime(departure.etd)}</td> // Show departed time if available, otherwise ETD
</tr> let timeDisplay;
`).join(''); if (departure.status === 'DEPARTED' && departure.departed_dt) {
const time = convertToLocalTime(departure.departed_dt);
timeDisplay = `<div style="display: flex; align-items: center; gap: 8px;"><span style="color: #3498db; font-weight: bold;">${time}</span><span style="font-size: 0.7em; background: #3498db; color: white; padding: 2px 4px; border-radius: 3px; white-space: nowrap;">DEPARTED</span></div>`;
} else {
timeDisplay = convertToLocalTime(departure.etd);
}
return `
<tr>
<td>${aircraftDisplay}</td>
<td>${escapeHtml(toDisplay)}</td>
<td>${timeDisplay}</td>
</tr>
`;
}));
tbody.innerHTML = rows.join('');
} catch (error) { } catch (error) {
console.error('Error loading departures:', error); console.error('Error loading departures:', error);
tbody.innerHTML = '<tr><td colspan="4" class="error">Error loading departures</td></tr>'; tbody.innerHTML = '<tr><td colspan="3" class="error">Error loading departures</td></tr>';
} }
} }