Live tab: resolve dep/arr for every aircraft via schedule cascade
OpenSky alone can't answer "where is this plane going" — /flights/aircraft
only returns landed flights, so an in-progress flight produced an empty
route card. Built a 3-tier resolution cascade that always lands somewhere
useful:
1) Scheduled lookup via route-explorer's /schedule endpoint. Parse the
ADS-B callsign (AAL3055 → carrier AA, number 3055), pull the day's
operating record, get real departure + arrival airports and times.
Works for every carrier route-explorer indexes (mainline + many
regionals). Smoke-tested live: AAL3055 → DFW→MIA, DAL1050 → IAH→MSP,
AAL1753 → XNA→DFW, AAL2978 → XNA→PHL. All from live aircraft caught
in the DFW area at the moment.
2) OpenSky historical (/flights/aircraft). If route-explorer doesn't
have the carrier, fall back to whatever OpenSky last logged for
this airframe. Labeled "LAST FLIGHT · 3h AGO" etc.
3) Trail-derived inference. Last resort for ICAO24s nothing knows about
(private jets, cargo, ad-hoc callsigns). Pull the OpenSky track,
take the first position, find the nearest airport in the bundled
3,900-entry DB (new AirportDatabase.nearestAirport(to:)). Shows
"Departed from KAUS — Austin Bergstrom" with "Heading to —"
acknowledging arrival is unknown.
Plumbed RouteExplorerClient through LiveFlightsView → RootView →
FlightsApp. Added searchSchedule(carrierCode:flightNumber:startDate:
endDate:) that returns [RouteFlight] directly (the /schedule envelope
is `{ flights: [...] }`, distinct from /route's `{ connections: [...] }`).
Three distinct route cards now render based on what we resolved:
- scheduled → green live dot + "Departed / Heading to"
- openSky → "Departed / Arrived" with age label
- inferred → "Departed from X / Heading to —"
- none → explicit "Route unavailable" message
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -155,6 +155,17 @@ struct RouteAppendixEquipment: Decodable, Sendable {
|
||||
|
||||
// MARK: - Search result
|
||||
|
||||
/// Response from `/schedule` — flat list of operating records for one
|
||||
/// (carrier, flightNumber, date). Different envelope from `/route` /
|
||||
/// `/departures` which return nested `connections[]`.
|
||||
struct RouteExplorerScheduleResponse: Decodable, Sendable {
|
||||
let json: Body
|
||||
struct Body: Decodable, Sendable {
|
||||
let flights: [RouteFlight]
|
||||
let appendix: RouteAppendix?
|
||||
}
|
||||
}
|
||||
|
||||
struct RouteSearchResult: Sendable {
|
||||
let connections: [RouteConnection]
|
||||
let appendix: RouteAppendix?
|
||||
|
||||
@@ -79,6 +79,28 @@ final class AirportDatabase: Sendable {
|
||||
airports.first { $0.iata == code }
|
||||
}
|
||||
|
||||
/// Return the airport closest to a given coordinate, optionally
|
||||
/// within a max distance. Linear scan — O(n) with ~3,900 airports,
|
||||
/// fast enough on the main thread for tap-then-lookup flows.
|
||||
func nearestAirport(to coordinate: CLLocationCoordinate2D, maxMiles: Double = 25) -> MapAirport? {
|
||||
guard !airports.isEmpty else { return nil }
|
||||
var bestAirport: MapAirport?
|
||||
var bestDistSq: Double = .greatestFiniteMagnitude
|
||||
// Convert max miles to (degrees lat)² in a rough planar sense — good
|
||||
// enough for "nearest airport" filtering. ~1 degree lat ≈ 69 miles.
|
||||
let cutoffSq = (maxMiles / 69.0) * (maxMiles / 69.0)
|
||||
for ap in airports {
|
||||
let dLat = ap.lat - coordinate.latitude
|
||||
let dLng = ap.lng - coordinate.longitude
|
||||
let dSq = dLat * dLat + dLng * dLng
|
||||
if dSq < bestDistSq {
|
||||
bestDistSq = dSq
|
||||
bestAirport = ap
|
||||
}
|
||||
}
|
||||
return bestDistSq <= cutoffSq ? bestAirport : nil
|
||||
}
|
||||
|
||||
private static func buildRegionNames() -> [String: String] {
|
||||
// US states + territories
|
||||
var names: [String: String] = [
|
||||
|
||||
@@ -85,6 +85,59 @@ actor RouteExplorerClient {
|
||||
return try await callFlightSearch(endpoint: "/route", json: payload)
|
||||
}
|
||||
|
||||
/// Schedule lookup for a specific flight number across a date range.
|
||||
/// Powers the live-flight detail sheet — given an ICAO callsign like
|
||||
/// `AAL1234` we resolve it to `(carrier: "AA", flightNumber: 1234)` and
|
||||
/// pull the operating record, which carries real departure + arrival
|
||||
/// airports and times.
|
||||
///
|
||||
/// Returns `nil` if the carrier/flight isn't in route-explorer's
|
||||
/// schedule feed (typical for regional codeshares, charter ops, and
|
||||
/// carriers the upstream platform doesn't index).
|
||||
func searchSchedule(
|
||||
carrierCode: String,
|
||||
flightNumber: Int,
|
||||
startDate: Date,
|
||||
endDate: Date? = nil
|
||||
) async -> [RouteFlight] {
|
||||
let startStr = dateFormatter.string(from: startDate)
|
||||
let endStr = dateFormatter.string(from: endDate ?? startDate)
|
||||
let payload: [String: Any] = [
|
||||
"carrierCode": carrierCode.uppercased(),
|
||||
"flightNumber": flightNumber,
|
||||
"startDate": startStr,
|
||||
"endDate": endStr,
|
||||
"limit": 20,
|
||||
"includeAppendix": true
|
||||
]
|
||||
do {
|
||||
let token = try await currentToken()
|
||||
let url = baseURL.appendingPathComponent("api/flight-search")
|
||||
let body = try JSONSerialization.data(withJSONObject: [
|
||||
"endpoint": "/schedule",
|
||||
"body": ["json": payload]
|
||||
])
|
||||
var req = URLRequest(url: url)
|
||||
req.httpMethod = "POST"
|
||||
Self.applyBrowserHeaders(to: &req)
|
||||
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
req.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
req.setValue(token, forHTTPHeaderField: "X-API-Token")
|
||||
req.httpBody = body
|
||||
|
||||
let (data, response) = try await session.data(for: req)
|
||||
let status = (response as? HTTPURLResponse)?.statusCode ?? -1
|
||||
guard (200...299).contains(status) else { return [] }
|
||||
|
||||
let decoded = try JSONDecoder.routeExplorer().decode(
|
||||
RouteExplorerScheduleResponse.self, from: data
|
||||
)
|
||||
return decoded.json.flights
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/// All departures from an airport on a date. We filter by time window
|
||||
/// client-side because the upstream endpoint doesn't accept one.
|
||||
func searchDepartures(
|
||||
|
||||
@@ -4,11 +4,27 @@ import CoreLocation
|
||||
struct LiveFlightDetailSheet: View {
|
||||
let aircraft: LiveAircraft
|
||||
let openSky: OpenSkyClient
|
||||
let routeExplorer: RouteExplorerClient
|
||||
let database: AirportDatabase
|
||||
|
||||
@State private var recentFlights: [OpenSkyFlight] = []
|
||||
@State private var isLoadingRoute = false
|
||||
|
||||
/// The resolved route for the current selection. Built from a cascade:
|
||||
/// scheduled flight (via route-explorer) → OpenSky history → trail-based
|
||||
/// nearest-airport inference. See `resolveRoute()`.
|
||||
@State private var resolvedRoute: ResolvedRoute?
|
||||
|
||||
enum ResolvedRoute {
|
||||
/// Real schedule match from route-explorer. Best fidelity.
|
||||
case scheduled(RouteFlight)
|
||||
/// OpenSky historical flight. departure + arrival from FAA tracking.
|
||||
case fromOpenSky(OpenSkyFlight, ageHours: Int)
|
||||
/// Couldn't find a flight record — inferred departure from the
|
||||
/// aircraft's trail start. Arrival unknown.
|
||||
case inferred(departureIATA: String, departureName: String?)
|
||||
}
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
@@ -62,11 +78,94 @@ struct LiveFlightDetailSheet: View {
|
||||
}
|
||||
}
|
||||
.task {
|
||||
await loadRoute()
|
||||
await resolveRoute()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Route resolution
|
||||
|
||||
/// Cascade to find departure + arrival, in order of fidelity:
|
||||
/// 1. Scheduled lookup via route-explorer (carrier + flight number)
|
||||
/// — this is the only path that gives a real arrival airport for
|
||||
/// an in-progress flight.
|
||||
/// 2. OpenSky `/flights/aircraft` — only landed flights; useful if
|
||||
/// the aircraft just landed or for "last flight" context.
|
||||
/// 3. Trail-based inference — first point of the OpenSky `/tracks/all`
|
||||
/// response → nearest airport in our local DB → "Departed from X".
|
||||
private func resolveRoute() async {
|
||||
isLoadingRoute = true
|
||||
defer { isLoadingRoute = false }
|
||||
|
||||
// 1) Try the scheduled lookup if we have a parseable airline + flight.
|
||||
if let scheduled = await tryScheduledLookup() {
|
||||
resolvedRoute = .scheduled(scheduled)
|
||||
// Still fetch OpenSky history in the background for the "recent
|
||||
// flights" list — but we don't need to await it.
|
||||
recentFlights = await openSky.recentFlights(icao24: aircraft.icao24)
|
||||
return
|
||||
}
|
||||
|
||||
// 2) Fall back to OpenSky history.
|
||||
let flights = await openSky.recentFlights(icao24: aircraft.icao24)
|
||||
recentFlights = flights
|
||||
if let mostRecent = flights.first {
|
||||
let hoursAgo = max(0, Int(
|
||||
(Date().timeIntervalSince1970 - Double(mostRecent.lastSeen)) / 3600
|
||||
))
|
||||
resolvedRoute = .fromOpenSky(mostRecent, ageHours: hoursAgo)
|
||||
return
|
||||
}
|
||||
|
||||
// 3) Last resort: nearest-airport from the trail start.
|
||||
if let track = await openSky.track(icao24: aircraft.icao24),
|
||||
let firstPoint = track.path.first {
|
||||
let coord = CLLocationCoordinate2D(
|
||||
latitude: firstPoint.latitude,
|
||||
longitude: firstPoint.longitude
|
||||
)
|
||||
if let nearest = database.nearestAirport(to: coord, maxMiles: 30) {
|
||||
resolvedRoute = .inferred(
|
||||
departureIATA: nearest.iata,
|
||||
departureName: nearest.name
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
resolvedRoute = nil
|
||||
}
|
||||
|
||||
/// Parse the ADS-B callsign (e.g. "AAL1234") into carrier + flight number
|
||||
/// and hit route-explorer's `/schedule` endpoint. Returns the day's
|
||||
/// operating record if route-explorer indexes the carrier; nil otherwise.
|
||||
private func tryScheduledLookup() async -> RouteFlight? {
|
||||
guard let icao = aircraft.airlineICAO,
|
||||
let airline = AircraftRegistry.shared.lookup(icao: icao),
|
||||
let iata = airline.iata,
|
||||
let flightStr = aircraft.flightNumber,
|
||||
let flightNum = Int(flightStr)
|
||||
else { return nil }
|
||||
|
||||
// Look across today ± yesterday — handles late-running redeyes that
|
||||
// departed on D-1 but are still airborne on D.
|
||||
let today = Date()
|
||||
let yesterday = today.addingTimeInterval(-86400)
|
||||
let results = await routeExplorer.searchSchedule(
|
||||
carrierCode: iata,
|
||||
flightNumber: flightNum,
|
||||
startDate: yesterday,
|
||||
endDate: today
|
||||
)
|
||||
guard !results.isEmpty else { return nil }
|
||||
|
||||
// Pick the entry whose departure is closest to "now" (typically
|
||||
// the in-progress flight).
|
||||
return results.min { a, b in
|
||||
abs(a.departure.dateTime.timeIntervalSinceNow) <
|
||||
abs(b.departure.dateTime.timeIntervalSinceNow)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Header
|
||||
|
||||
private var header: some View {
|
||||
@@ -173,46 +272,37 @@ struct LiveFlightDetailSheet: View {
|
||||
// label it clearly: "In flight" if the snapshot is fresh, "Last flight"
|
||||
// if it's older, "—" only when we genuinely have nothing.
|
||||
|
||||
private enum RouteStatus { case loading, inFlight, recent(hoursAgo: Int), none }
|
||||
|
||||
@ViewBuilder
|
||||
private var routeSection: some View {
|
||||
if isLoadingRoute && recentFlights.isEmpty {
|
||||
HStack {
|
||||
ProgressView()
|
||||
Text("Looking up route…")
|
||||
.font(.caption)
|
||||
.foregroundStyle(FlightTheme.textSecondary)
|
||||
}
|
||||
} else if let mostRecent = recentFlights.first {
|
||||
let now = Date().timeIntervalSince1970
|
||||
let hoursAgo = max(0, Int((now - Double(mostRecent.lastSeen)) / 3600))
|
||||
let status: RouteStatus = {
|
||||
if aircraft.onGround { return .recent(hoursAgo: hoursAgo) }
|
||||
if hoursAgo < 6 { return .inFlight }
|
||||
return .recent(hoursAgo: hoursAgo)
|
||||
}()
|
||||
if let resolved = resolvedRoute {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(spacing: 8) {
|
||||
Text(routeHeader(status))
|
||||
Text(headerLabel(for: resolved))
|
||||
.font(FlightTheme.label())
|
||||
.foregroundStyle(FlightTheme.textTertiary)
|
||||
.tracking(1)
|
||||
if case .inFlight = status {
|
||||
if case .scheduled = resolved {
|
||||
Circle()
|
||||
.fill(FlightTheme.onTime)
|
||||
.frame(width: 6, height: 6)
|
||||
}
|
||||
}
|
||||
routeCard(mostRecent, status: status)
|
||||
resolvedRouteCard(resolved)
|
||||
}
|
||||
} else if isLoadingRoute {
|
||||
HStack {
|
||||
ProgressView()
|
||||
Text("Resolving route…")
|
||||
.font(.caption)
|
||||
.foregroundStyle(FlightTheme.textSecondary)
|
||||
}
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("THIS FLIGHT")
|
||||
Text("ROUTE")
|
||||
.font(FlightTheme.label())
|
||||
.foregroundStyle(FlightTheme.textTertiary)
|
||||
.tracking(1)
|
||||
Text("Route not available from OpenSky for this aircraft.")
|
||||
Text("Route unavailable. This aircraft isn't in any schedule source we cover (callsign \(aircraft.trimmedCallsign ?? aircraft.icao24)).")
|
||||
.font(.caption)
|
||||
.foregroundStyle(FlightTheme.textSecondary)
|
||||
.padding(12)
|
||||
@@ -222,44 +312,105 @@ struct LiveFlightDetailSheet: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func routeHeader(_ status: RouteStatus) -> String {
|
||||
switch status {
|
||||
case .loading: return "THIS FLIGHT"
|
||||
case .inFlight: return "IN FLIGHT"
|
||||
case .recent(let hoursAgo):
|
||||
private func headerLabel(for r: ResolvedRoute) -> String {
|
||||
switch r {
|
||||
case .scheduled:
|
||||
return aircraft.onGround ? "FLIGHT (ON GROUND)" : "IN FLIGHT"
|
||||
case .fromOpenSky(_, let hoursAgo):
|
||||
if hoursAgo < 1 { return "LAST FLIGHT · JUST LANDED" }
|
||||
if hoursAgo < 24 { return "LAST FLIGHT · \(hoursAgo)h AGO" }
|
||||
let days = hoursAgo / 24
|
||||
return "LAST FLIGHT · \(days)d AGO"
|
||||
case .none: return "THIS FLIGHT"
|
||||
return "LAST FLIGHT · \(hoursAgo / 24)d AGO"
|
||||
case .inferred:
|
||||
return aircraft.onGround ? "ON GROUND" : "IN FLIGHT (INFERRED)"
|
||||
}
|
||||
}
|
||||
|
||||
private func routeCard(_ f: OpenSkyFlight, status: RouteStatus) -> some View {
|
||||
let depLabel: String
|
||||
let arrLabel: String
|
||||
switch status {
|
||||
case .inFlight:
|
||||
depLabel = "Departed"
|
||||
arrLabel = "Heading to"
|
||||
case .recent:
|
||||
depLabel = "Departed"
|
||||
arrLabel = "Arrived"
|
||||
case .loading, .none:
|
||||
depLabel = "From"
|
||||
arrLabel = "To"
|
||||
}
|
||||
return VStack(alignment: .leading, spacing: 12) {
|
||||
@ViewBuilder
|
||||
private func resolvedRouteCard(_ r: ResolvedRoute) -> some View {
|
||||
switch r {
|
||||
case .scheduled(let f):
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(spacing: 16) {
|
||||
routeEndpoint(code: f.estDepartureAirport, label: depLabel, time: f.departureDate)
|
||||
routeEndpoint(
|
||||
code: f.departure.airportIata,
|
||||
label: aircraft.onGround ? "From" : "Departed",
|
||||
time: f.departure.dateTime
|
||||
)
|
||||
Image(systemName: "airplane")
|
||||
.font(.title3)
|
||||
.foregroundStyle(FlightTheme.accent)
|
||||
.rotationEffect(.degrees(-45))
|
||||
routeEndpoint(code: f.estArrivalAirport, label: arrLabel, time: f.arrivalDate)
|
||||
routeEndpoint(
|
||||
code: f.arrival.airportIata,
|
||||
label: aircraft.onGround ? "To" : "Heading to",
|
||||
time: f.arrival.dateTime
|
||||
)
|
||||
}
|
||||
}
|
||||
.flightCard()
|
||||
|
||||
case .fromOpenSky(let f, _):
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(spacing: 16) {
|
||||
routeEndpoint(
|
||||
code: f.estDepartureAirport,
|
||||
label: "Departed",
|
||||
time: f.departureDate
|
||||
)
|
||||
Image(systemName: "airplane")
|
||||
.font(.title3)
|
||||
.foregroundStyle(FlightTheme.accent)
|
||||
.rotationEffect(.degrees(-45))
|
||||
routeEndpoint(
|
||||
code: f.estArrivalAirport,
|
||||
label: "Arrived",
|
||||
time: f.arrivalDate
|
||||
)
|
||||
}
|
||||
}
|
||||
.flightCard()
|
||||
|
||||
case .inferred(let depIata, let depName):
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(spacing: 16) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Departed")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(FlightTheme.textTertiary)
|
||||
.tracking(0.5)
|
||||
Text(depIata)
|
||||
.font(FlightTheme.airportCode(28))
|
||||
.foregroundStyle(FlightTheme.textPrimary)
|
||||
if let depName {
|
||||
Text(depName)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(FlightTheme.textSecondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
Image(systemName: "airplane")
|
||||
.font(.title3)
|
||||
.foregroundStyle(FlightTheme.accent)
|
||||
.rotationEffect(.degrees(-45))
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Heading to")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(FlightTheme.textTertiary)
|
||||
.tracking(0.5)
|
||||
Text("—")
|
||||
.font(FlightTheme.airportCode(28))
|
||||
.foregroundStyle(FlightTheme.textTertiary)
|
||||
Text("Not in schedule data")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(FlightTheme.textSecondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
.flightCard()
|
||||
}
|
||||
}
|
||||
|
||||
private func routeEndpoint(code: String?, label: String, time: Date) -> some View {
|
||||
@@ -352,13 +503,6 @@ struct LiveFlightDetailSheet: View {
|
||||
?? "Unknown"
|
||||
}
|
||||
|
||||
private func loadRoute() async {
|
||||
guard !isLoadingRoute else { return }
|
||||
isLoadingRoute = true
|
||||
defer { isLoadingRoute = false }
|
||||
recentFlights = await openSky.recentFlights(icao24: aircraft.icao24)
|
||||
}
|
||||
|
||||
private func formatNumber(_ n: Int) -> String {
|
||||
let f = NumberFormatter()
|
||||
f.numberStyle = .decimal
|
||||
|
||||
@@ -4,6 +4,7 @@ import CoreLocation
|
||||
|
||||
struct LiveFlightsView: View {
|
||||
let openSky: OpenSkyClient
|
||||
let routeExplorer: RouteExplorerClient
|
||||
let database: AirportDatabase
|
||||
|
||||
// MARK: - Map state
|
||||
@@ -80,7 +81,12 @@ struct LiveFlightsView: View {
|
||||
.sheet(item: $activeSheet) { sheet in
|
||||
switch sheet {
|
||||
case .aircraft(let ac):
|
||||
LiveFlightDetailSheet(aircraft: ac, openSky: openSky, database: database)
|
||||
LiveFlightDetailSheet(
|
||||
aircraft: ac,
|
||||
openSky: openSky,
|
||||
routeExplorer: routeExplorer,
|
||||
database: database
|
||||
)
|
||||
.presentationDetents([.medium, .large])
|
||||
.presentationDragIndicator(.visible)
|
||||
case .settings:
|
||||
|
||||
@@ -27,7 +27,11 @@ struct RootView: View {
|
||||
.tag(Tab.search)
|
||||
|
||||
NavigationStack {
|
||||
LiveFlightsView(openSky: openSky, database: database)
|
||||
LiveFlightsView(
|
||||
openSky: openSky,
|
||||
routeExplorer: routeExplorer,
|
||||
database: database
|
||||
)
|
||||
.navigationTitle("Live Flights")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user