diff --git a/backend/app/api/endpoints/public.py b/backend/app/api/endpoints/public.py index 63847d2..04fe39e 100644 --- a/backend/app/api/endpoints/public.py +++ b/backend/app/api/endpoints/public.py @@ -59,28 +59,36 @@ async def get_public_arrivals(db: Session = Depends(get_db)): 'isLocalFlight': False }) - # Add local flights with DEPARTED status that were booked out today - local_flights = crud_local_flight.get_multi( - db, - status=LocalFlightStatus.DEPARTED, - limit=1000 - ) - # Get today's date boundaries today = date.today() today_start = datetime.combine(today, datetime.min.time()) today_end = datetime.combine(today + timedelta(days=1), datetime.min.time()) + + # Add airborne local flights that were booked out today. + # Admin now moves local flights from GROUND to LOCAL/CIRCUIT rather than DEPARTED. + airborne_local_statuses = { + LocalFlightStatus.DEPARTED, + LocalFlightStatus.LOCAL, + LocalFlightStatus.CIRCUIT, + LocalFlightStatus.CIRCUIT_DOWNWIND, + LocalFlightStatus.CIRCUIT_BASE, + LocalFlightStatus.CIRCUIT_FINAL, + } + local_flights = crud_local_flight.get_multi(db, limit=1000) # Convert local flights to match the PPR format for display for flight in local_flights: # Only include flights booked out today if not (today_start <= flight.created_dt < today_end): continue + if flight.status not in airborne_local_statuses: + continue - # Calculate ETA from departed_dt + duration (if both are available) - eta = flight.departed_dt - if flight.departed_dt and flight.duration: - eta = flight.departed_dt + timedelta(minutes=flight.duration) + # Calculate ETA from actual takeoff/departure + duration, falling back to ETD. + departure_time = flight.takeoff_dt or flight.departed_dt or flight.etd + eta = departure_time + if departure_time and flight.duration: + eta = departure_time + timedelta(minutes=flight.duration) arrivals_list.append({ 'ac_call': flight.callsign or flight.registration, @@ -89,7 +97,7 @@ async def get_public_arrivals(db: Session = Depends(get_db)): 'in_from': None, 'eta': eta, 'landed_dt': None, - 'status': 'DEPARTED', + 'status': flight.status.value, 'isLocalFlight': True, 'flight_type': flight.flight_type.value }) @@ -143,23 +151,26 @@ async def get_public_departures(db: Session = Depends(get_db)): 'isDeparture': False }) - # Add local flights with BOOKED_OUT status that were booked out today - local_flights = crud_local_flight.get_multi( - db, - status=LocalFlightStatus.BOOKED_OUT, - limit=1000 - ) - # Get today's date boundaries today = date.today() today_start = datetime.combine(today, datetime.min.time()) today_end = datetime.combine(today + timedelta(days=1), datetime.min.time()) + + # Add local flights awaiting takeoff that were booked out today. + # Admin-created flights start at GROUND, while public pilot submissions start at BOOKED_OUT. + local_departure_statuses = { + LocalFlightStatus.BOOKED_OUT, + LocalFlightStatus.GROUND, + } + local_flights = crud_local_flight.get_multi(db, limit=1000) # Convert local flights to match the PPR format for display for flight in local_flights: # Only include flights booked out today if not (today_start <= flight.created_dt < today_end): continue + if flight.status not in local_departure_statuses: + continue departures_list.append({ 'ac_call': flight.callsign or flight.registration, 'ac_reg': flight.registration, @@ -167,7 +178,7 @@ async def get_public_departures(db: Session = Depends(get_db)): 'out_to': None, 'etd': flight.etd or flight.created_dt, 'departed_dt': None, - 'status': 'BOOKED_OUT', + 'status': 'CONTACT' if flight.status == LocalFlightStatus.GROUND else 'BOOKED_OUT', 'isLocalFlight': True, 'flight_type': flight.flight_type.value, 'isDeparture': False @@ -247,4 +258,4 @@ async def get_ui_config(): "top_bar_gradient_end": lighten_color(base_color, 0.4), # Lighten for gradient end "footer_color": darken_color(base_color, 0.2), # Darken for footer "environment": settings.environment - } \ No newline at end of file + } diff --git a/backend/tests/test_public_api.py b/backend/tests/test_public_api.py index f9b768d..c358e08 100644 --- a/backend/tests/test_public_api.py +++ b/backend/tests/test_public_api.py @@ -81,6 +81,71 @@ def test_public_boards_include_todays_flights(client, db): } +def test_public_boards_include_current_local_flight_statuses(client, db): + now = datetime.now().replace(microsecond=0) + ground_local = LocalFlight( + registration="G-GRND", + type="C152", + callsign="GGRND", + pob=1, + flight_type=LocalFlightType.LOCAL, + status=LocalFlightStatus.GROUND, + created_dt=now, + etd=now, + ) + airborne_local = LocalFlight( + registration="G-AIR1", + type="PA28", + callsign="GAIR1", + pob=2, + flight_type=LocalFlightType.LOCAL, + status=LocalFlightStatus.LOCAL, + created_dt=now, + etd=now, + takeoff_dt=now, + duration=45, + ) + circuit_local = LocalFlight( + registration="G-CCT1", + type="C152", + callsign="GCCT1", + pob=1, + flight_type=LocalFlightType.CIRCUITS, + status=LocalFlightStatus.CIRCUIT, + created_dt=now, + etd=now, + takeoff_dt=now, + duration=30, + ) + landed_local = LocalFlight( + registration="G-LND1", + type="C172", + callsign="GLND1", + pob=1, + flight_type=LocalFlightType.LOCAL, + status=LocalFlightStatus.LANDED, + created_dt=now, + etd=now, + takeoff_dt=now, + landed_dt=now, + ) + db.add_all([ground_local, airborne_local, circuit_local, landed_local]) + db.commit() + + arrivals = client.get("/api/v1/public/arrivals") + departures = client.get("/api/v1/public/departures") + + assert arrivals.status_code == 200 + assert { + item["ac_reg"] for item in arrivals.json() if item.get("isLocalFlight") + } == {"G-AIR1", "G-CCT1"} + + assert departures.status_code == 200 + assert { + item["ac_reg"] for item in departures.json() if item.get("isLocalFlight") + } == {"G-GRND"} + + def test_public_reference_lookups_return_seeded_records(client, db): db.add( Airport( diff --git a/web/admin.html b/web/admin.html index 651e53a..45b041c 100644 --- a/web/admin.html +++ b/web/admin.html @@ -48,8 +48,47 @@
- +
+
+
+ 🛩️ Today's Local Flights - 0 + ℹ️ +
+
+ +
+
+ Loading local flights... +
+ + + + +
+ + +
🛬 Today's Pending Arrivals - 0 @@ -292,22 +331,21 @@ loadPPRsTimeout = setTimeout(async () => { // Load all tables simultaneously - await Promise.all([loadArrivals(), loadDepartures(), loadOverflights(), loadDeparted(), loadParked(), loadUpcoming()]); + await Promise.all([loadArrivals(), loadDepartures(), loadLocalFlights(), loadOverflights(), loadDeparted(), loadParked(), loadUpcoming()]); loadPPRsTimeout = null; }, 100); // Wait 100ms before executing to batch multiple calls } - // Load arrivals (NEW and CONFIRMED status for PPR, DEPARTED for local flights) + // Load arrivals (NEW and CONFIRMED status for PPR only) async function loadArrivals() { document.getElementById('arrivals-loading').style.display = 'block'; document.getElementById('arrivals-table-content').style.display = 'none'; document.getElementById('arrivals-no-data').style.display = 'none'; try { - // Load PPRs, local flights, and booked-in arrivals - const [pprResponse, localResponse, bookInResponse] = await Promise.all([ + // Load PPRs and booked-in arrivals + const [pprResponse, bookInResponse] = await Promise.all([ authenticatedFetch('/api/v1/pprs/?limit=1000'), - authenticatedFetch('/api/v1/local-flights/?status=DEPARTED&limit=1000'), authenticatedFetch('/api/v1/arrivals/?limit=1000') ]); @@ -328,24 +366,6 @@ return etaDate === today; }); - // Add local flights in DEPARTED status (in the air, heading back) - only those booked out today - if (localResponse.ok) { - const localFlights = await localResponse.json(); - const today = new Date().toISOString().split('T')[0]; - const localInAir = localFlights - .filter(flight => { - // Only include flights booked out today (created_dt) - if (!flight.created_dt) return false; - const createdDate = flight.created_dt.split('T')[0]; - return createdDate === today; - }) - .map(flight => ({ - ...flight, - isLocalFlight: true // Flag to distinguish from PPR - })); - arrivals.push(...localInAir); - } - // Add booked-in arrivals from the arrivals table if (bookInResponse.ok) { const bookedInArrivals = await bookInResponse.json(); @@ -375,20 +395,16 @@ document.getElementById('arrivals-loading').style.display = 'none'; } - // Load departures (LANDED status for PPR, GROUND/LOCAL for local flights) + // Load departures (LANDED status for PPR plus non-local airport departures) async function loadDepartures() { document.getElementById('departures-loading').style.display = 'block'; document.getElementById('departures-table-content').style.display = 'none'; document.getElementById('departures-no-data').style.display = 'none'; try { - // Load PPR departures, local flight departures, and airport departures simultaneously - const [pprResponse, localBookedOutResponse, localOutGroundResponse, localLocalResponse, localCircuitResponse, depBookedOutResponse, depOutGroundResponse, depLocalResponse] = await Promise.all([ + // Load PPR departures and airport departures simultaneously + const [pprResponse, depBookedOutResponse, depOutGroundResponse, depLocalResponse] = await Promise.all([ authenticatedFetch('/api/v1/pprs/?limit=1000'), - authenticatedFetch('/api/v1/local-flights/?status=BOOKED_OUT&limit=1000'), - authenticatedFetch('/api/v1/local-flights/?status=GROUND&limit=1000'), - authenticatedFetch('/api/v1/local-flights/?status=LOCAL&limit=1000'), - authenticatedFetch('/api/v1/local-flights/?status=CIRCUIT&limit=1000'), authenticatedFetch('/api/v1/departures/?status=BOOKED_OUT&limit=1000'), authenticatedFetch('/api/v1/departures/?status=GROUND&limit=1000'), authenticatedFetch('/api/v1/departures/?status=LOCAL&limit=1000') @@ -399,16 +415,10 @@ } const allPPRs = await pprResponse.json(); - const localBookedOut = localBookedOutResponse.ok ? await localBookedOutResponse.json() : []; - const localOutGround = localOutGroundResponse.ok ? await localOutGroundResponse.json() : []; - const localLocal = localLocalResponse.ok ? await localLocalResponse.json() : []; - const localCircuit = localCircuitResponse.ok ? await localCircuitResponse.json() : []; const depBookedOut = depBookedOutResponse.ok ? await depBookedOutResponse.json() : []; const depOutGround = depOutGroundResponse.ok ? await depOutGroundResponse.json() : []; const depLocal = depLocalResponse.ok ? await depLocalResponse.json() : []; - // Combine local flights - const allLocalFlights = [...localBookedOut, ...localOutGround, ...localLocal, ...localCircuit]; // Combine departures const allDepartures = [...depBookedOut, ...depOutGround, ...depLocal]; const today = new Date().toISOString().split('T')[0]; @@ -423,20 +433,6 @@ return etdDate === today; }); - // Add local flights (GROUND and LOCAL status - ready to go) - only those booked out today - const localDepartures = allLocalFlights - .filter(flight => { - // Only include flights booked out today (created_dt) - if (!flight.created_dt) return false; - const createdDate = flight.created_dt.split('T')[0]; - return createdDate === today; - }) - .map(flight => ({ - ...flight, - isLocalFlight: true // Flag to distinguish from PPR - })); - departures.push(...localDepartures); - // Add departures to other airports (BOOKED_OUT, GROUND, and LOCAL status) const depDepartures = allDepartures.map(flight => ({ ...flight, @@ -455,6 +451,35 @@ document.getElementById('departures-loading').style.display = 'none'; } + async function loadLocalFlights() { + document.getElementById('local-flights-loading').style.display = 'block'; + document.getElementById('local-flights-table-content').style.display = 'none'; + document.getElementById('local-flights-no-data').style.display = 'none'; + + try { + const response = await authenticatedFetch('/api/v1/local-flights/?limit=1000'); + + if (!response.ok) { + throw new Error('Failed to fetch local flights'); + } + + const today = new Date().toISOString().split('T')[0]; + const localFlights = (await response.json()).filter(flight => { + if (!flight.created_dt || ['CANCELLED', 'LANDED'].includes(flight.status)) return false; + return flight.created_dt.split('T')[0] === today; + }); + + displayLocalFlights(localFlights); + } catch (error) { + console.error('Error loading local flights:', error); + if (error.message !== 'Session expired. Please log in again.') { + showNotification('Error loading local flights', true); + } + } + + document.getElementById('local-flights-loading').style.display = 'none'; + } + // Load overflights (ACTIVE status only) async function loadOverflights() { document.getElementById('overflights-loading').style.display = 'block'; @@ -1011,9 +1036,6 @@ // Different action buttons based on status if (flight.status === 'INBOUND') { actionButtons = ` - @@ -1027,9 +1049,6 @@ T&G `; actionButtons = ` - ${circuitButton} `; actionButtons = ` - ${circuitButton} @@ -1111,6 +1124,115 @@ setupTooltips(); } + function localFlightStatusBadge(status) { + const labels = { + BOOKED_OUT: 'BOOKED OUT', + GROUND: 'GROUND', + DEPARTED: 'AIRBORNE', + LOCAL: 'LOCAL', + CIRCUIT: 'CIRCUIT', + CIRCUIT_DOWNWIND: 'DOWNWIND', + CIRCUIT_BASE: 'BASE', + CIRCUIT_FINAL: 'FINAL', + LANDED: 'LANDED' + }; + return `${labels[status] || status || '-'}`; + } + + async function displayLocalFlights(localFlights) { + const tbody = document.getElementById('local-flights-table-body'); + const recordCount = document.getElementById('local-flights-count'); + + recordCount.textContent = localFlights.length; + if (localFlights.length === 0) { + document.getElementById('local-flights-no-data').style.display = 'block'; + return; + } + + localFlights.sort((a, b) => { + const aTime = a.etd || a.created_dt; + const bTime = b.etd || b.created_dt; + if (!aTime) return 1; + if (!bTime) return -1; + return new Date(aTime) - new Date(bTime); + }); + + tbody.innerHTML = ''; + document.getElementById('local-flights-table-content').style.display = 'block'; + + const circuitCounts = await loadLocalFlightCircuitCounts(localFlights); + + for (const flight of localFlights) { + const row = document.createElement('tr'); + row.onclick = () => openLocalFlightEditModal(flight.id); + + const aircraftDisplay = flight.callsign && flight.callsign.trim() + ? `${flight.callsign}
${flight.registration}` + : `${flight.registration}`; + const typeIcon = flight.submitted_via === 'PUBLIC' + ? 'O' + : 'L'; + const flightType = flight.flight_type === 'CIRCUITS' ? 'Circuits' : flight.flight_type === 'LOCAL' ? 'Local' : 'Departure'; + const etd = flight.etd ? formatTimeOnly(flight.etd) : (flight.created_dt ? formatTimeOnly(flight.created_dt) : '-'); + const circuits = circuitCounts[flight.id] ?? flight.circuits ?? 0; + + let actionButtons = ''; + if (flight.status === 'BOOKED_OUT') { + actionButtons = ` + + `; + } else if (flight.status === 'GROUND') { + const takeoffStatus = flight.flight_type === 'CIRCUITS' ? 'CIRCUIT' : 'LOCAL'; + actionButtons = ` + + `; + } else if (['DEPARTED', 'LOCAL', 'CIRCUIT', 'CIRCUIT_DOWNWIND', 'CIRCUIT_BASE', 'CIRCUIT_FINAL'].includes(flight.status)) { + actionButtons = ` + + + `; + } else { + actionButtons = '-'; + } + + row.innerHTML = ` + ${aircraftDisplay} + ${typeIcon} + ${flight.type || '-'} + ${flightType} + ${etd} + ${flight.pob || '-'} + ${localFlightStatusBadge(flight.status)} + ${circuits} + ${actionButtons} + `; + tbody.appendChild(row); + } + } + + async function loadLocalFlightCircuitCounts(localFlights) { + const counts = {}; + await Promise.all(localFlights.map(async (flight) => { + try { + const response = await authenticatedFetch(`/api/v1/circuits/flight/${flight.id}`); + if (!response.ok) return; + const circuits = await response.json(); + counts[flight.id] = circuits.length; + } catch (error) { + console.warn(`Unable to load circuits for local flight ${flight.id}:`, error); + } + })); + return counts; + } + function pprNeedsStripAck(ppr) { return (ppr.status === 'NEW' || ppr.status === 'CONFIRMED') && !ppr.acknowledged_dt; } @@ -1134,27 +1256,6 @@ } } - async function activatePPR(pprId, acReg, hasDeparture) { - const msg = `Activate PPR for ${acReg}?\nThis will create an INBOUND arrival.` - + (hasDeparture ? '\nThe outbound departure will appear automatically when the aircraft lands.' : ''); - if (!confirm(msg)) return; - - try { - const response = await authenticatedFetch(`/api/v1/pprs/${pprId}/activate`, { method: 'POST' }); - if (!response.ok) { - const err = await response.json().catch(() => ({})); - showNotification(err.detail || 'Failed to activate PPR', true); - return; - } - const result = await response.json(); - showNotification(result.message || 'PPR activated'); - await loadPPRs(); - } catch (error) { - console.error('Error activating PPR:', error); - showNotification('Error activating PPR', true); - } - } - async function displayDepartures(departures) { const tbody = document.getElementById('departures-table-body'); const recordCount = document.getElementById('departures-count'); @@ -1245,8 +1346,8 @@ `; } else if (flight.status === 'LOCAL') { actionButtons = ` - `; } else if (flight.status === 'CIRCUIT') { @@ -1255,9 +1356,6 @@ T&G `; actionButtons = ` - ${circuitButton} `; } else if (flight.status === 'CIRCUIT') { actionButtons = ` - @@ -1472,7 +1567,7 @@ // Override: admin uses loadDepartures after circuit save async function afterCircuitSaved() { closeCircuitModal(); - loadDepartures(); + loadLocalFlights(); } diff --git a/web/shared.js b/web/shared.js index 914c191..333c37b 100644 --- a/web/shared.js +++ b/web/shared.js @@ -904,6 +904,9 @@ // Circuit modal functions function showCircuitModal(localFlightId = null, arrivalId = null) { + localFlightId = localFlightId || currentLocalFlightId; + arrivalId = arrivalId || currentArrivalId; + if (!localFlightId && !arrivalId) return; // Set the current IDs @@ -2593,11 +2596,15 @@ const tableHelpTexts = { arrivals: { title: "Today's Pending Arrivals", - text: "Displays aircraft that are expected to arrive at Swansea today. These are flights that have filed PPRs or have been booked in as arriving. Aircraft in this list are actively planning to land today." + text: "Displays aircraft that are expected to arrive at Swansea today. These are PPR arrivals and aircraft booked in as arrivals." }, departures: { title: "Today's Pending Departures", - text: "Displays aircraft that are ready to depart from Swansea today. This includes flights with approved PPRs awaiting departure, local flights that have been booked out, and departures to other airfields. These aircraft will depart today." + text: "Displays visiting aircraft and airport departures that are ready to leave Swansea today. Local flights are shown in their own table." + }, + "local-flights": { + title: "Today's Local Flights", + text: "Displays local and circuit flights booked out today, with shortcuts for contact, takeoff, circuit work, touch-and-go, and landing." }, overflights: { title: "Active Overflights",