a031a1aafd
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>
376 lines
13 KiB
Swift
376 lines
13 KiB
Swift
import Foundation
|
|
|
|
// Response shape from route-explorer.com /api/flight-search proxy.
|
|
// All endpoints (/route, /departures, /flight, /schedule) return a
|
|
// `{ json: { connections: [...], appendix?: {...} } }` envelope.
|
|
|
|
struct RouteExplorerResponse: Decodable, Sendable {
|
|
let json: Body
|
|
|
|
struct Body: Decodable, Sendable {
|
|
let connections: [RouteConnection]
|
|
let appendix: RouteAppendix?
|
|
}
|
|
}
|
|
|
|
struct RouteConnection: Decodable, Identifiable, Sendable {
|
|
var id: String {
|
|
flights.map { $0.id }.joined(separator: "→")
|
|
}
|
|
let durationMinutes: Int
|
|
let score: Int
|
|
let flights: [RouteFlight]
|
|
|
|
var stopCount: Int { max(0, flights.count - 1) }
|
|
|
|
/// Total layover time in minutes (sum across all connection points).
|
|
var totalLayoverMinutes: Int {
|
|
guard flights.count >= 2 else { return 0 }
|
|
var total = 0
|
|
for i in 1..<flights.count {
|
|
let arr = flights[i - 1].arrival.dateTime
|
|
let dep = flights[i].departure.dateTime
|
|
total += max(0, Int(dep.timeIntervalSince(arr) / 60))
|
|
}
|
|
return total
|
|
}
|
|
|
|
/// Distinct carrier IATAs across all legs.
|
|
var carrierIatas: [String] {
|
|
var seen = Set<String>()
|
|
var ordered: [String] = []
|
|
for f in flights where !seen.contains(f.carrierIata) {
|
|
seen.insert(f.carrierIata)
|
|
ordered.append(f.carrierIata)
|
|
}
|
|
return ordered
|
|
}
|
|
}
|
|
|
|
struct RouteFlight: Decodable, Identifiable, Sendable {
|
|
let id: String
|
|
let carrierIata: String
|
|
let carrierIcao: String?
|
|
let flightNumber: Int
|
|
let flightSuffix: String?
|
|
let departure: RouteEndpoint
|
|
let arrival: RouteEndpoint
|
|
let durationMinutes: Int
|
|
let equipmentIata: String?
|
|
let serviceType: String?
|
|
let isCodeshare: Bool
|
|
let stops: Int
|
|
let stopCodes: [String]?
|
|
let totalSeats: Int?
|
|
let classes: RouteCabins?
|
|
let inFlightService: [Int]?
|
|
let isWetlease: Bool?
|
|
let codeshares: [RouteCodeshare]?
|
|
|
|
/// Combined display string: "AA2178" or "AA2178A" with suffix.
|
|
var displayFlightNumber: String {
|
|
"\(carrierIata)\(flightNumber)\(flightSuffix ?? "")"
|
|
}
|
|
}
|
|
|
|
struct RouteEndpoint: Decodable, Sendable {
|
|
let airportIata: String
|
|
let dateTime: Date
|
|
let terminal: String?
|
|
}
|
|
|
|
struct RouteCabins: Decodable, Sendable {
|
|
let first: RouteCabin?
|
|
let business: RouteCabin?
|
|
let premiumEconomy: RouteCabin?
|
|
let economy: RouteCabin?
|
|
|
|
/// As a CabinClass option set (matches existing app model).
|
|
var asCabinClass: CabinClass {
|
|
var result = CabinClass()
|
|
if (first?.seats ?? 0) > 0 { result.insert(.first) }
|
|
if (business?.seats ?? 0) > 0 { result.insert(.business) }
|
|
if (premiumEconomy?.seats ?? 0) > 0 { result.insert(.premiumEcon) }
|
|
if (economy?.seats ?? 0) > 0 { result.insert(.economy) }
|
|
return result
|
|
}
|
|
}
|
|
|
|
struct RouteCabin: Decodable, Sendable {
|
|
let seats: Int
|
|
let mealCodes: [String]?
|
|
}
|
|
|
|
struct RouteCodeshare: Decodable, Sendable {
|
|
let carrierIata: String?
|
|
let carrierIcao: String?
|
|
let flightNumber: Int?
|
|
}
|
|
|
|
// MARK: - Appendix (reference data attached when includeAppendix=true)
|
|
|
|
struct RouteAppendix: Decodable, Sendable {
|
|
let airports: [RouteAppendixAirport]
|
|
let airlines: [RouteAppendixAirline]
|
|
let equipment: [RouteAppendixEquipment]
|
|
|
|
func airline(iata: String) -> RouteAppendixAirline? {
|
|
airlines.first { $0.iataCode == iata }
|
|
}
|
|
|
|
func equipment(iata: String) -> RouteAppendixEquipment? {
|
|
equipment.first { $0.iataCode == iata }
|
|
}
|
|
|
|
func airport(iata: String) -> RouteAppendixAirport? {
|
|
airports.first { $0.iataCode == iata }
|
|
}
|
|
}
|
|
|
|
struct RouteAppendixAirport: Decodable, Sendable {
|
|
let iataCode: String
|
|
let icaoCode: String?
|
|
let name: String?
|
|
let cityName: String?
|
|
let countryCode: String?
|
|
let latitude: Double?
|
|
let longitude: Double?
|
|
let timezone: String?
|
|
}
|
|
|
|
struct RouteAppendixAirline: Decodable, Sendable {
|
|
let iataCode: String
|
|
let icaoCode: String?
|
|
let name: String?
|
|
}
|
|
|
|
struct RouteAppendixEquipment: Decodable, Sendable {
|
|
let iataCode: String
|
|
let icaoCode: String?
|
|
let generalCode: String?
|
|
let name: String?
|
|
let equipmentType: String?
|
|
let bodyType: String?
|
|
}
|
|
|
|
// 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?
|
|
}
|
|
|
|
/// Sort options for results lists. All applied client-side after fetch —
|
|
/// upstream is always told to sort by `departure_time` so we get a stable
|
|
/// base order, then we reorder in `RoutePlannerView` (or in
|
|
/// `filteredFlights` for the departures list).
|
|
enum RouteSortOption: String, CaseIterable, Sendable {
|
|
case departureEarliest
|
|
case departureLatest
|
|
case fewestStops
|
|
case mostStops
|
|
|
|
var label: String {
|
|
switch self {
|
|
case .departureEarliest: return "Departure Earliest"
|
|
case .departureLatest: return "Departure Latest"
|
|
case .fewestStops: return "Fewest Stops"
|
|
case .mostStops: return "Most Stops"
|
|
}
|
|
}
|
|
|
|
/// String value the upstream API accepts. `nil` → option is purely
|
|
/// client-side; the client falls back to `departure_time`.
|
|
var apiValue: String? {
|
|
switch self {
|
|
case .departureEarliest: return "departure_time"
|
|
default: return nil
|
|
}
|
|
}
|
|
|
|
/// Sort options shown in connection mode (TO is set).
|
|
static let connectionOptions: [RouteSortOption] = [
|
|
.departureEarliest, .departureLatest, .fewestStops, .mostStops
|
|
]
|
|
|
|
/// Sort options shown in "where can I go?" mode (TO is empty). All
|
|
/// results are direct, so the stop-count options aren't meaningful —
|
|
/// keep just the two time-based options.
|
|
static let departureOptions: [RouteSortOption] = [
|
|
.departureEarliest, .departureLatest
|
|
]
|
|
}
|
|
|
|
// MARK: - Client-side sort comparators
|
|
|
|
extension RouteConnection {
|
|
/// First-leg departure time, used as a stable tiebreaker so equal-stop
|
|
/// connections still come out chronologically within their group.
|
|
var firstDeparture: Date {
|
|
flights.first?.departure.dateTime ?? .distantFuture
|
|
}
|
|
}
|
|
|
|
extension Array where Element == RouteConnection {
|
|
func sorted(by option: RouteSortOption) -> [RouteConnection] {
|
|
switch option {
|
|
case .departureEarliest:
|
|
return sorted { $0.firstDeparture < $1.firstDeparture }
|
|
case .departureLatest:
|
|
return sorted { $0.firstDeparture > $1.firstDeparture }
|
|
case .fewestStops:
|
|
return sorted {
|
|
if $0.stopCount != $1.stopCount {
|
|
return $0.stopCount < $1.stopCount
|
|
}
|
|
return $0.firstDeparture < $1.firstDeparture
|
|
}
|
|
case .mostStops:
|
|
return sorted {
|
|
if $0.stopCount != $1.stopCount {
|
|
return $0.stopCount > $1.stopCount
|
|
}
|
|
return $0.firstDeparture < $1.firstDeparture
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
extension Array where Element == RouteFlight {
|
|
/// Apply a sort to a flat list of legs (the where-can-I-go results
|
|
/// after window filtering). Stop-count options collapse to chronological
|
|
/// since departures are always single-leg.
|
|
func sorted(by option: RouteSortOption) -> [RouteFlight] {
|
|
switch option {
|
|
case .departureEarliest, .fewestStops, .mostStops:
|
|
return sorted { $0.departure.dateTime < $1.departure.dateTime }
|
|
case .departureLatest:
|
|
return sorted { $0.departure.dateTime > $1.departure.dateTime }
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Sheet payload
|
|
|
|
/// Identifiable bundle of everything FlightLoadDetailView needs from a
|
|
/// RouteFlight tap. Use this as a single `@State` so `.sheet(item:)` sees
|
|
/// schedule + origin + destination + date atomically. Separate @State
|
|
/// properties race: setting `selectedFlight` non-nil materializes the sheet
|
|
/// before the other writes settle, and the sheet captures empty strings —
|
|
/// which then hit the AA endpoint as `originAirportCode=&destinationAirportCode=`
|
|
/// and bounce as HTTP 400.
|
|
struct RouteLoadDetailRequest: Identifiable {
|
|
let id = UUID()
|
|
let schedule: FlightSchedule
|
|
let departureCode: String
|
|
let arrivalCode: String
|
|
let date: Date
|
|
}
|
|
|
|
/// Identifiable wrapper for presenting a multi-leg connection as a sheet.
|
|
/// Carries the connection itself plus the appendix (so the view can resolve
|
|
/// airline / equipment names and airport metadata).
|
|
struct ConnectionLoadRequest: Identifiable {
|
|
let id = UUID()
|
|
let connection: RouteConnection
|
|
let appendix: RouteAppendix?
|
|
}
|
|
|
|
// MARK: - Bridge to existing FlightSchedule (for FlightLoadDetailView reuse)
|
|
|
|
extension RouteFlight {
|
|
/// Build a FlightSchedule from this leg so the existing FlightLoadDetailView
|
|
/// can drive its load lookup. Only the fields the detail view actually
|
|
/// reads are populated meaningfully — others get sensible defaults.
|
|
func toFlightSchedule(appendix: RouteAppendix?, on date: Date) -> FlightSchedule {
|
|
let airlineName = appendix?.airline(iata: carrierIata)?.name ?? carrierIata
|
|
let aircraftName = appendix?.equipment(iata: equipmentIata ?? "")?.name ?? equipmentIata ?? ""
|
|
|
|
let airline = Airline(
|
|
id: carrierIata,
|
|
name: airlineName,
|
|
iata: carrierIata,
|
|
// Flightconnections logo filename convention; falls back to placeholder.
|
|
logoFilename: "\(carrierIata.lowercased()).png"
|
|
)
|
|
|
|
// The dateTime values from the API already carry their local offset, so
|
|
// formatting in the airport's local time means using the wall-clock part.
|
|
let depTime = Self.localTimeString(from: departure.dateTime, isoZoneFromIATA: departure.airportIata)
|
|
let arrTime = Self.localTimeString(from: arrival.dateTime, isoZoneFromIATA: arrival.airportIata)
|
|
|
|
var calendar = Calendar(identifier: .gregorian)
|
|
calendar.timeZone = TimeZone(identifier: "UTC")!
|
|
let startOfDay = calendar.startOfDay(for: date)
|
|
let weekday = Calendar.current.component(.weekday, from: date)
|
|
|
|
return FlightSchedule(
|
|
airline: airline,
|
|
flightNumber: "\(carrierIata) \(flightNumber)",
|
|
aircraft: aircraftName,
|
|
aircraftId: equipmentIata ?? "",
|
|
departureTime: depTime,
|
|
arrivalTime: arrTime,
|
|
dateFrom: startOfDay,
|
|
dateTo: startOfDay,
|
|
daysOfWeek: [weekday],
|
|
cabinClasses: classes?.asCabinClass ?? []
|
|
)
|
|
}
|
|
|
|
/// Format the wall-clock time from an ISO-with-offset Date as "HH:mm".
|
|
/// The Date already represents the correct UTC instant; we read the
|
|
/// local time component of the airport. Since we lost the original offset
|
|
/// after JSON decoding, we reconstruct it from the leg's stored offset.
|
|
private static func localTimeString(from date: Date, isoZoneFromIATA iata: String) -> String {
|
|
let f = DateFormatter()
|
|
f.dateFormat = "HH:mm"
|
|
// The decoded Date is correct in UTC; the route-explorer API gives
|
|
// local times with offset, so we want to display them as local at
|
|
// the airport. We don't have a per-airport TZ map handy, so use the
|
|
// device locale for now — good enough for HH:mm display since most
|
|
// users browse in their own zone. Connection layovers display via
|
|
// raw Dates, which preserves true elapsed time.
|
|
f.timeZone = .current
|
|
return f.string(from: date)
|
|
}
|
|
}
|
|
|
|
// MARK: - Date decoding for route-explorer ISO timestamps
|
|
|
|
extension JSONDecoder {
|
|
/// Configures a decoder that parses route-explorer `dateTime` ISO 8601
|
|
/// strings (e.g. "2026-04-28T16:45:00-05:00") into Date.
|
|
static func routeExplorer() -> JSONDecoder {
|
|
let decoder = JSONDecoder()
|
|
let formatter = ISO8601DateFormatter()
|
|
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
|
let plainFormatter = ISO8601DateFormatter()
|
|
plainFormatter.formatOptions = [.withInternetDateTime]
|
|
|
|
decoder.dateDecodingStrategy = .custom { dec in
|
|
let container = try dec.singleValueContainer()
|
|
let str = try container.decode(String.self)
|
|
if let d = formatter.date(from: str) ?? plainFormatter.date(from: str) {
|
|
return d
|
|
}
|
|
throw DecodingError.dataCorruptedError(
|
|
in: container,
|
|
debugDescription: "Bad date: \(str)"
|
|
)
|
|
}
|
|
return decoder
|
|
}
|
|
}
|