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:
Trey T
2026-05-27 06:48:26 -05:00
parent ddfcf3e0e4
commit a031a1aafd
6 changed files with 307 additions and 67 deletions
+11
View File
@@ -155,6 +155,17 @@ struct RouteAppendixEquipment: Decodable, Sendable {
// MARK: - Search result // 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 { struct RouteSearchResult: Sendable {
let connections: [RouteConnection] let connections: [RouteConnection]
let appendix: RouteAppendix? let appendix: RouteAppendix?
+22
View File
@@ -79,6 +79,28 @@ final class AirportDatabase: Sendable {
airports.first { $0.iata == code } 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] { private static func buildRegionNames() -> [String: String] {
// US states + territories // US states + territories
var names: [String: String] = [ var names: [String: String] = [
@@ -85,6 +85,59 @@ actor RouteExplorerClient {
return try await callFlightSearch(endpoint: "/route", json: payload) 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 /// All departures from an airport on a date. We filter by time window
/// client-side because the upstream endpoint doesn't accept one. /// client-side because the upstream endpoint doesn't accept one.
func searchDepartures( func searchDepartures(
+205 -61
View File
@@ -4,11 +4,27 @@ import CoreLocation
struct LiveFlightDetailSheet: View { struct LiveFlightDetailSheet: View {
let aircraft: LiveAircraft let aircraft: LiveAircraft
let openSky: OpenSkyClient let openSky: OpenSkyClient
let routeExplorer: RouteExplorerClient
let database: AirportDatabase let database: AirportDatabase
@State private var recentFlights: [OpenSkyFlight] = [] @State private var recentFlights: [OpenSkyFlight] = []
@State private var isLoadingRoute = false @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 @Environment(\.dismiss) private var dismiss
var body: some View { var body: some View {
@@ -62,11 +78,94 @@ struct LiveFlightDetailSheet: View {
} }
} }
.task { .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 // MARK: - Header
private var header: some View { private var header: some View {
@@ -173,46 +272,37 @@ struct LiveFlightDetailSheet: View {
// label it clearly: "In flight" if the snapshot is fresh, "Last flight" // label it clearly: "In flight" if the snapshot is fresh, "Last flight"
// if it's older, "" only when we genuinely have nothing. // if it's older, "" only when we genuinely have nothing.
private enum RouteStatus { case loading, inFlight, recent(hoursAgo: Int), none }
@ViewBuilder @ViewBuilder
private var routeSection: some View { private var routeSection: some View {
if isLoadingRoute && recentFlights.isEmpty { if let resolved = resolvedRoute {
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)
}()
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 8) { HStack(spacing: 8) {
Text(routeHeader(status)) Text(headerLabel(for: resolved))
.font(FlightTheme.label()) .font(FlightTheme.label())
.foregroundStyle(FlightTheme.textTertiary) .foregroundStyle(FlightTheme.textTertiary)
.tracking(1) .tracking(1)
if case .inFlight = status { if case .scheduled = resolved {
Circle() Circle()
.fill(FlightTheme.onTime) .fill(FlightTheme.onTime)
.frame(width: 6, height: 6) .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 { } else {
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
Text("THIS FLIGHT") Text("ROUTE")
.font(FlightTheme.label()) .font(FlightTheme.label())
.foregroundStyle(FlightTheme.textTertiary) .foregroundStyle(FlightTheme.textTertiary)
.tracking(1) .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) .font(.caption)
.foregroundStyle(FlightTheme.textSecondary) .foregroundStyle(FlightTheme.textSecondary)
.padding(12) .padding(12)
@@ -222,44 +312,105 @@ struct LiveFlightDetailSheet: View {
} }
} }
private func routeHeader(_ status: RouteStatus) -> String { private func headerLabel(for r: ResolvedRoute) -> String {
switch status { switch r {
case .loading: return "THIS FLIGHT" case .scheduled:
case .inFlight: return "IN FLIGHT" return aircraft.onGround ? "FLIGHT (ON GROUND)" : "IN FLIGHT"
case .recent(let hoursAgo): case .fromOpenSky(_, let hoursAgo):
if hoursAgo < 1 { return "LAST FLIGHT · JUST LANDED" } if hoursAgo < 1 { return "LAST FLIGHT · JUST LANDED" }
if hoursAgo < 24 { return "LAST FLIGHT · \(hoursAgo)h AGO" } if hoursAgo < 24 { return "LAST FLIGHT · \(hoursAgo)h AGO" }
let days = hoursAgo / 24 return "LAST FLIGHT · \(hoursAgo / 24)d AGO"
return "LAST FLIGHT · \(days)d AGO" case .inferred:
case .none: return "THIS FLIGHT" return aircraft.onGround ? "ON GROUND" : "IN FLIGHT (INFERRED)"
} }
} }
private func routeCard(_ f: OpenSkyFlight, status: RouteStatus) -> some View { @ViewBuilder
let depLabel: String private func resolvedRouteCard(_ r: ResolvedRoute) -> some View {
let arrLabel: String switch r {
switch status { case .scheduled(let f):
case .inFlight: VStack(alignment: .leading, spacing: 12) {
depLabel = "Departed" HStack(spacing: 16) {
arrLabel = "Heading to" routeEndpoint(
case .recent: code: f.departure.airportIata,
depLabel = "Departed" label: aircraft.onGround ? "From" : "Departed",
arrLabel = "Arrived" time: f.departure.dateTime
case .loading, .none: )
depLabel = "From" Image(systemName: "airplane")
arrLabel = "To" .font(.title3)
} .foregroundStyle(FlightTheme.accent)
return VStack(alignment: .leading, spacing: 12) { .rotationEffect(.degrees(-45))
HStack(spacing: 16) { routeEndpoint(
routeEndpoint(code: f.estDepartureAirport, label: depLabel, time: f.departureDate) code: f.arrival.airportIata,
Image(systemName: "airplane") label: aircraft.onGround ? "To" : "Heading to",
.font(.title3) time: f.arrival.dateTime
.foregroundStyle(FlightTheme.accent) )
.rotationEffect(.degrees(-45)) }
routeEndpoint(code: f.estArrivalAirport, label: arrLabel, time: f.arrivalDate)
} }
.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()
} }
.flightCard()
} }
private func routeEndpoint(code: String?, label: String, time: Date) -> some View { private func routeEndpoint(code: String?, label: String, time: Date) -> some View {
@@ -352,13 +503,6 @@ struct LiveFlightDetailSheet: View {
?? "Unknown" ?? "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 { private func formatNumber(_ n: Int) -> String {
let f = NumberFormatter() let f = NumberFormatter()
f.numberStyle = .decimal f.numberStyle = .decimal
+9 -3
View File
@@ -4,6 +4,7 @@ import CoreLocation
struct LiveFlightsView: View { struct LiveFlightsView: View {
let openSky: OpenSkyClient let openSky: OpenSkyClient
let routeExplorer: RouteExplorerClient
let database: AirportDatabase let database: AirportDatabase
// MARK: - Map state // MARK: - Map state
@@ -80,9 +81,14 @@ struct LiveFlightsView: View {
.sheet(item: $activeSheet) { sheet in .sheet(item: $activeSheet) { sheet in
switch sheet { switch sheet {
case .aircraft(let ac): case .aircraft(let ac):
LiveFlightDetailSheet(aircraft: ac, openSky: openSky, database: database) LiveFlightDetailSheet(
.presentationDetents([.medium, .large]) aircraft: ac,
.presentationDragIndicator(.visible) openSky: openSky,
routeExplorer: routeExplorer,
database: database
)
.presentationDetents([.medium, .large])
.presentationDragIndicator(.visible)
case .settings: case .settings:
OpenSkySettingsView() OpenSkySettingsView()
} }
+7 -3
View File
@@ -27,9 +27,13 @@ struct RootView: View {
.tag(Tab.search) .tag(Tab.search)
NavigationStack { NavigationStack {
LiveFlightsView(openSky: openSky, database: database) LiveFlightsView(
.navigationTitle("Live Flights") openSky: openSky,
.navigationBarTitleDisplayMode(.inline) routeExplorer: routeExplorer,
database: database
)
.navigationTitle("Live Flights")
.navigationBarTitleDisplayMode(.inline)
} }
.tabItem { .tabItem {
Label("Live", systemImage: "antenna.radiowaves.left.and.right") Label("Live", systemImage: "antenna.radiowaves.left.and.right")