Files
Flights/Flights/Models/RouteExplorerModels.swift
T
Trey T a031a1aafd 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>
2026-05-27 06:48:26 -05:00

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
}
}