Add route-explorer.com integration: connection finder + departures board
- RouteExplorerClient: anonymous HMAC token (route-explorer.com/api/token,
IP rate-limited 10/min), POST /api/flight-search with X-API-Token; auto
retry on 401/403 token rotation. Wraps the SuperJSON {json:{...}} envelope
for the upstream tRPC endpoints.
- RouteExplorerModels: Codable types for /route, /departures responses
(RouteConnection, RouteFlight, cabins, appendix). Custom ISO-8601
decoder for the dateTime-with-offset timestamps. Bridge helper
RouteFlight.toFlightSchedule(...) so route-explorer legs reuse the
existing FlightLoadDetailView and AirlineLoadService flow for
supported carriers (UA/AA/NK/KE/B6/AS/EK/XE).
- RoutePlannerView: feature (a) — direct + multi-stop A→B routing via
/route with maxStops 0/1/2, sortBy departure_time/duration, optional
interline-only filter. Renders one ConnectionRow per itinerary with
chained legs and layover indicators.
- WhereToGoView: feature (b) — "where can I go" departures board for an
airport over a 2/4/6/12/24h window. Capacity pills (F/J/W/Y), color-
coded countdown, cross-midnight rollover. Tap any leg → load detail.
- IATAAirportPicker: lightweight local-only picker against
AirportDatabase (no flightconnections roundtrip needed since
route-explorer keys on IATA, not FC IDs).
- ContentView: two new entry-point cards (Find Connections, Where can I
go?) above the favorites list.
- api_docs/route_explorer_api.md + captures: full endpoint reference and
representative response samples (DFW→LAS direct, DFW→KOA 1-stop,
LBB→KOA 2-stop, AA2178 schedule, DFW departures).
No tests yet — project has no test target and adding TDD would require
scaffolding XCTest first. Worth backfilling tests for the date decoder,
layover math, and toFlightSchedule bridge using the saved fixtures.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -39,6 +39,12 @@
|
|||||||
E62A922EC7924273BDF14005 /* RouteMapView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2574CDD727284621BBB56145 /* RouteMapView.swift */; };
|
E62A922EC7924273BDF14005 /* RouteMapView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2574CDD727284621BBB56145 /* RouteMapView.swift */; };
|
||||||
9124DA69A89F4E90A35DD13C /* WebViewFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22867394CDCC423891007AE1 /* WebViewFetcher.swift */; };
|
9124DA69A89F4E90A35DD13C /* WebViewFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22867394CDCC423891007AE1 /* WebViewFetcher.swift */; };
|
||||||
BB2200002222000022220001 /* JSXWebViewFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB2200002222000022220002 /* JSXWebViewFetcher.swift */; };
|
BB2200002222000022220001 /* JSXWebViewFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB2200002222000022220002 /* JSXWebViewFetcher.swift */; };
|
||||||
|
RE1100001111000011110001 /* RouteExplorerModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = RE1100001111000011110002 /* RouteExplorerModels.swift */; };
|
||||||
|
RE2200002222000022220001 /* RouteExplorerClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = RE2200002222000022220002 /* RouteExplorerClient.swift */; };
|
||||||
|
RE3300003333000033330001 /* RoutePlannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = RE3300003333000033330002 /* RoutePlannerView.swift */; };
|
||||||
|
RE4400004444000044440001 /* WhereToGoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = RE4400004444000044440002 /* WhereToGoView.swift */; };
|
||||||
|
RE5500005555000055550001 /* IATAAirportPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = RE5500005555000055550002 /* IATAAirportPicker.swift */; };
|
||||||
|
RE6600006666000066660001 /* ConnectionRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = RE6600006666000066660002 /* ConnectionRow.swift */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
@@ -75,6 +81,12 @@
|
|||||||
2574CDD727284621BBB56145 /* RouteMapView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouteMapView.swift; sourceTree = "<group>"; };
|
2574CDD727284621BBB56145 /* RouteMapView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouteMapView.swift; sourceTree = "<group>"; };
|
||||||
22867394CDCC423891007AE1 /* WebViewFetcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewFetcher.swift; sourceTree = "<group>"; };
|
22867394CDCC423891007AE1 /* WebViewFetcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewFetcher.swift; sourceTree = "<group>"; };
|
||||||
BB2200002222000022220002 /* JSXWebViewFetcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSXWebViewFetcher.swift; sourceTree = "<group>"; };
|
BB2200002222000022220002 /* JSXWebViewFetcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSXWebViewFetcher.swift; sourceTree = "<group>"; };
|
||||||
|
RE1100001111000011110002 /* RouteExplorerModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouteExplorerModels.swift; sourceTree = "<group>"; };
|
||||||
|
RE2200002222000022220002 /* RouteExplorerClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouteExplorerClient.swift; sourceTree = "<group>"; };
|
||||||
|
RE3300003333000033330002 /* RoutePlannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoutePlannerView.swift; sourceTree = "<group>"; };
|
||||||
|
RE4400004444000044440002 /* WhereToGoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WhereToGoView.swift; sourceTree = "<group>"; };
|
||||||
|
RE5500005555000055550002 /* IATAAirportPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IATAAirportPicker.swift; sourceTree = "<group>"; };
|
||||||
|
RE6600006666000066660002 /* ConnectionRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionRow.swift; sourceTree = "<group>"; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
@@ -100,6 +112,8 @@
|
|||||||
7C2EB471F011450DA7BBEFAD /* AirportMapView.swift */,
|
7C2EB471F011450DA7BBEFAD /* AirportMapView.swift */,
|
||||||
15676B4BD35745D1BD1DC947 /* AirportBrowserSheet.swift */,
|
15676B4BD35745D1BD1DC947 /* AirportBrowserSheet.swift */,
|
||||||
BB1100001111000011110006 /* FlightLoadDetailView.swift */,
|
BB1100001111000011110006 /* FlightLoadDetailView.swift */,
|
||||||
|
RE3300003333000033330002 /* RoutePlannerView.swift */,
|
||||||
|
RE4400004444000044440002 /* WhereToGoView.swift */,
|
||||||
AA5555555555555555555555 /* Styles */,
|
AA5555555555555555555555 /* Styles */,
|
||||||
AA6666666666666666666666 /* Components */,
|
AA6666666666666666666666 /* Components */,
|
||||||
);
|
);
|
||||||
@@ -118,6 +132,8 @@
|
|||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
AA4444444444444444444444 /* RouteVisualization.swift */,
|
AA4444444444444444444444 /* RouteVisualization.swift */,
|
||||||
|
RE5500005555000055550002 /* IATAAirportPicker.swift */,
|
||||||
|
RE6600006666000066660002 /* ConnectionRow.swift */,
|
||||||
);
|
);
|
||||||
path = Components;
|
path = Components;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -163,6 +179,7 @@
|
|||||||
9A58C339D6084657B0538E9C /* AirportDatabase.swift */,
|
9A58C339D6084657B0538E9C /* AirportDatabase.swift */,
|
||||||
BB1100001111000011110004 /* AirlineLoadService.swift */,
|
BB1100001111000011110004 /* AirlineLoadService.swift */,
|
||||||
BB2200002222000022220002 /* JSXWebViewFetcher.swift */,
|
BB2200002222000022220002 /* JSXWebViewFetcher.swift */,
|
||||||
|
RE2200002222000022220002 /* RouteExplorerClient.swift */,
|
||||||
);
|
);
|
||||||
path = Services;
|
path = Services;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -187,6 +204,7 @@
|
|||||||
4944338B20BA4AB98F05D4F7 /* BrowseAirport.swift */,
|
4944338B20BA4AB98F05D4F7 /* BrowseAirport.swift */,
|
||||||
E7987BD4832D44F1A0851933 /* Country.swift */,
|
E7987BD4832D44F1A0851933 /* Country.swift */,
|
||||||
BB1100001111000011110002 /* FlightLoad.swift */,
|
BB1100001111000011110002 /* FlightLoad.swift */,
|
||||||
|
RE1100001111000011110002 /* RouteExplorerModels.swift */,
|
||||||
);
|
);
|
||||||
path = Models;
|
path = Models;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -286,6 +304,12 @@
|
|||||||
E62A922EC7924273BDF14005 /* RouteMapView.swift in Sources */,
|
E62A922EC7924273BDF14005 /* RouteMapView.swift in Sources */,
|
||||||
9124DA69A89F4E90A35DD13C /* WebViewFetcher.swift in Sources */,
|
9124DA69A89F4E90A35DD13C /* WebViewFetcher.swift in Sources */,
|
||||||
BB2200002222000022220001 /* JSXWebViewFetcher.swift in Sources */,
|
BB2200002222000022220001 /* JSXWebViewFetcher.swift in Sources */,
|
||||||
|
RE1100001111000011110001 /* RouteExplorerModels.swift in Sources */,
|
||||||
|
RE2200002222000022220001 /* RouteExplorerClient.swift in Sources */,
|
||||||
|
RE3300003333000033330001 /* RoutePlannerView.swift in Sources */,
|
||||||
|
RE4400004444000044440001 /* WhereToGoView.swift in Sources */,
|
||||||
|
RE5500005555000055550001 /* IATAAirportPicker.swift in Sources */,
|
||||||
|
RE6600006666000066660001 /* ConnectionRow.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ struct FlightsApp: App {
|
|||||||
let database: AirportDatabase
|
let database: AirportDatabase
|
||||||
let favoritesManager = FavoritesManager()
|
let favoritesManager = FavoritesManager()
|
||||||
let loadService: AirlineLoadService
|
let loadService: AirlineLoadService
|
||||||
|
let routeExplorer = RouteExplorerClient()
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
let db = AirportDatabase()
|
let db = AirportDatabase()
|
||||||
@@ -19,7 +20,8 @@ struct FlightsApp: App {
|
|||||||
service: service,
|
service: service,
|
||||||
database: database,
|
database: database,
|
||||||
loadService: loadService,
|
loadService: loadService,
|
||||||
favoritesManager: favoritesManager
|
favoritesManager: favoritesManager,
|
||||||
|
routeExplorer: routeExplorer
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,260 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
struct RouteSearchResult: Sendable {
|
||||||
|
let connections: [RouteConnection]
|
||||||
|
let appendix: RouteAppendix?
|
||||||
|
}
|
||||||
|
|
||||||
|
enum RouteSortOption: String, CaseIterable, Sendable {
|
||||||
|
case departureTime = "departure_time"
|
||||||
|
case duration = "duration"
|
||||||
|
|
||||||
|
var label: String {
|
||||||
|
switch self {
|
||||||
|
case .departureTime: return "Departure"
|
||||||
|
case .duration: return "Duration"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Talks to the public route-explorer.com API surface (StaffTraveler's web
|
||||||
|
/// proxy). No login required — `/api/token` issues an anonymous HMAC token,
|
||||||
|
/// IP-rate-limited at 10/min, that we attach as `X-API-Token` to subsequent
|
||||||
|
/// `/api/flight-search` calls.
|
||||||
|
///
|
||||||
|
/// Endpoint allowlist exposed by the proxy: `/route`, `/route-batch`,
|
||||||
|
/// `/flight`, `/departures`, `/schedule`. We use `/route` (with `maxStops`)
|
||||||
|
/// for connection finding and `/departures` for the "where can I go" view.
|
||||||
|
actor RouteExplorerClient {
|
||||||
|
|
||||||
|
// MARK: - Errors
|
||||||
|
|
||||||
|
enum ClientError: Error, LocalizedError {
|
||||||
|
case tokenFetchFailed(status: Int)
|
||||||
|
case requestFailed(status: Int, body: String?)
|
||||||
|
case decodingFailed(underlying: Error)
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .tokenFetchFailed(let status):
|
||||||
|
return "Could not get an API token (HTTP \(status))."
|
||||||
|
case .requestFailed(let status, let body):
|
||||||
|
return "Request failed (HTTP \(status)). \(body ?? "")"
|
||||||
|
case .decodingFailed(let error):
|
||||||
|
return "Could not parse response: \(error.localizedDescription)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Properties
|
||||||
|
|
||||||
|
private let session: URLSession
|
||||||
|
private let baseURL = URL(string: "https://route-explorer.com")!
|
||||||
|
private let dateFormatter: DateFormatter
|
||||||
|
|
||||||
|
/// Cached token. Tokens have a server-side TTL we don't know exactly;
|
||||||
|
/// we refresh defensively after 30 minutes or on first use.
|
||||||
|
private var cachedToken: (value: String, expiresAt: Date)?
|
||||||
|
|
||||||
|
// MARK: - Init
|
||||||
|
|
||||||
|
init() {
|
||||||
|
let config = URLSessionConfiguration.default
|
||||||
|
config.timeoutIntervalForRequest = 20
|
||||||
|
config.requestCachePolicy = .reloadIgnoringLocalCacheData
|
||||||
|
session = URLSession(configuration: config)
|
||||||
|
|
||||||
|
let f = DateFormatter()
|
||||||
|
f.calendar = Calendar(identifier: .gregorian)
|
||||||
|
f.locale = Locale(identifier: "en_US_POSIX")
|
||||||
|
f.timeZone = TimeZone(identifier: "UTC")
|
||||||
|
f.dateFormat = "yyyy-MM-dd"
|
||||||
|
dateFormatter = f
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Public search API
|
||||||
|
|
||||||
|
/// Find direct + multi-stop itineraries between two airports on one date.
|
||||||
|
func searchRoutes(
|
||||||
|
from origin: String,
|
||||||
|
to destination: String,
|
||||||
|
date: Date,
|
||||||
|
maxStops: Int = 1,
|
||||||
|
includeInterline: Bool = false,
|
||||||
|
sortBy: RouteSortOption = .departureTime,
|
||||||
|
limit: Int = 100
|
||||||
|
) async throws -> RouteSearchResult {
|
||||||
|
let dateStr = dateFormatter.string(from: date)
|
||||||
|
let payload: [String: Any] = [
|
||||||
|
"departureAirportIata": origin.uppercased(),
|
||||||
|
"arrivalAirportIata": destination.uppercased(),
|
||||||
|
"departureDates": [dateStr],
|
||||||
|
"maxStops": maxStops,
|
||||||
|
"sortBy": sortBy.rawValue,
|
||||||
|
"includeInterline": includeInterline,
|
||||||
|
"limit": limit,
|
||||||
|
"includeAppendix": true
|
||||||
|
]
|
||||||
|
return try await callFlightSearch(endpoint: "/route", json: payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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(
|
||||||
|
from origin: String,
|
||||||
|
date: Date,
|
||||||
|
maxStops: Int = 0,
|
||||||
|
limit: Int = 200
|
||||||
|
) async throws -> RouteSearchResult {
|
||||||
|
let dateStr = dateFormatter.string(from: date)
|
||||||
|
let payload: [String: Any] = [
|
||||||
|
"departureAirportIata": origin.uppercased(),
|
||||||
|
"departureDates": [dateStr],
|
||||||
|
"maxStops": maxStops,
|
||||||
|
"limit": limit,
|
||||||
|
"includeAppendix": true
|
||||||
|
]
|
||||||
|
return try await callFlightSearch(endpoint: "/departures", json: payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Token
|
||||||
|
|
||||||
|
private func currentToken() async throws -> String {
|
||||||
|
if let cached = cachedToken, cached.expiresAt > Date() {
|
||||||
|
return cached.value
|
||||||
|
}
|
||||||
|
let url = baseURL.appendingPathComponent("api/token")
|
||||||
|
var req = URLRequest(url: url)
|
||||||
|
req.httpMethod = "GET"
|
||||||
|
req.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||||
|
|
||||||
|
let (data, response) = try await session.data(for: req)
|
||||||
|
let status = (response as? HTTPURLResponse)?.statusCode ?? -1
|
||||||
|
guard status == 200 else {
|
||||||
|
throw ClientError.tokenFetchFailed(status: status)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TokenResponse: Decodable { let token: String }
|
||||||
|
do {
|
||||||
|
let decoded = try JSONDecoder().decode(TokenResponse.self, from: data)
|
||||||
|
cachedToken = (decoded.token, Date().addingTimeInterval(30 * 60))
|
||||||
|
return decoded.token
|
||||||
|
} catch {
|
||||||
|
throw ClientError.decodingFailed(underlying: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Flight search proxy
|
||||||
|
|
||||||
|
private func callFlightSearch(
|
||||||
|
endpoint: String,
|
||||||
|
json: [String: Any]
|
||||||
|
) async throws -> RouteSearchResult {
|
||||||
|
let token = try await currentToken()
|
||||||
|
let url = baseURL.appendingPathComponent("api/flight-search")
|
||||||
|
|
||||||
|
let outerBody: [String: Any] = [
|
||||||
|
"endpoint": endpoint,
|
||||||
|
"body": ["json": json]
|
||||||
|
]
|
||||||
|
let bodyData = try JSONSerialization.data(withJSONObject: outerBody)
|
||||||
|
|
||||||
|
var req = URLRequest(url: url)
|
||||||
|
req.httpMethod = "POST"
|
||||||
|
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
|
req.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||||
|
req.setValue(token, forHTTPHeaderField: "X-API-Token")
|
||||||
|
req.httpBody = bodyData
|
||||||
|
|
||||||
|
let (data, response) = try await session.data(for: req)
|
||||||
|
let status = (response as? HTTPURLResponse)?.statusCode ?? -1
|
||||||
|
|
||||||
|
// 401 / 403 likely means the token rotated. Drop cache and retry once.
|
||||||
|
if status == 401 || status == 403 {
|
||||||
|
cachedToken = nil
|
||||||
|
return try await retryAfterTokenRotation(endpoint: endpoint, json: json)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard (200...299).contains(status) else {
|
||||||
|
let bodyStr = String(data: data, encoding: .utf8)
|
||||||
|
throw ClientError.requestFailed(status: status, body: bodyStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
return try decode(data: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func retryAfterTokenRotation(
|
||||||
|
endpoint: String,
|
||||||
|
json: [String: Any]
|
||||||
|
) async throws -> RouteSearchResult {
|
||||||
|
let token = try await currentToken()
|
||||||
|
let url = baseURL.appendingPathComponent("api/flight-search")
|
||||||
|
|
||||||
|
let outerBody: [String: Any] = [
|
||||||
|
"endpoint": endpoint,
|
||||||
|
"body": ["json": json]
|
||||||
|
]
|
||||||
|
let bodyData = try JSONSerialization.data(withJSONObject: outerBody)
|
||||||
|
|
||||||
|
var req = URLRequest(url: url)
|
||||||
|
req.httpMethod = "POST"
|
||||||
|
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
|
req.setValue(token, forHTTPHeaderField: "X-API-Token")
|
||||||
|
req.httpBody = bodyData
|
||||||
|
|
||||||
|
let (data, response) = try await session.data(for: req)
|
||||||
|
let status = (response as? HTTPURLResponse)?.statusCode ?? -1
|
||||||
|
guard (200...299).contains(status) else {
|
||||||
|
throw ClientError.requestFailed(status: status, body: String(data: data, encoding: .utf8))
|
||||||
|
}
|
||||||
|
return try decode(data: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func decode(data: Data) throws -> RouteSearchResult {
|
||||||
|
do {
|
||||||
|
let response = try JSONDecoder.routeExplorer().decode(RouteExplorerResponse.self, from: data)
|
||||||
|
return RouteSearchResult(
|
||||||
|
connections: response.json.connections,
|
||||||
|
appendix: response.json.appendix
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
throw ClientError.decodingFailed(underlying: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Renders one `RouteConnection` (1+ chained legs) as a single tappable card.
|
||||||
|
/// Each leg is independently tappable via the inner buttons — tapping the
|
||||||
|
/// outer card just expands/collapses the leg detail by default; we wire
|
||||||
|
/// per-leg taps via the `onLegTap` callback.
|
||||||
|
struct ConnectionRow: View {
|
||||||
|
let connection: RouteConnection
|
||||||
|
let appendix: RouteAppendix?
|
||||||
|
let onLegTap: (RouteFlight) -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
// MARK: - Summary header
|
||||||
|
summaryHeader
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
// MARK: - Legs
|
||||||
|
VStack(spacing: 10) {
|
||||||
|
ForEach(Array(connection.flights.enumerated()), id: \.element.id) { index, leg in
|
||||||
|
if index > 0, let layover = layoverMinutes(at: index) {
|
||||||
|
layoverRow(minutes: layover, at: leg.departure.airportIata)
|
||||||
|
}
|
||||||
|
Button {
|
||||||
|
onLegTap(leg)
|
||||||
|
} label: {
|
||||||
|
LegSummary(leg: leg, appendix: appendix)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.flightCard()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Summary header
|
||||||
|
|
||||||
|
private var summaryHeader: some View {
|
||||||
|
HStack(alignment: .center) {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(stopsLabel)
|
||||||
|
.font(.caption.weight(.semibold))
|
||||||
|
.foregroundStyle(FlightTheme.accent)
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 3)
|
||||||
|
.background(FlightTheme.accent.opacity(0.12), in: Capsule())
|
||||||
|
|
||||||
|
Text(carriersLabel)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(FlightTheme.textSecondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
VStack(alignment: .trailing, spacing: 2) {
|
||||||
|
Text(formatDuration(connection.durationMinutes))
|
||||||
|
.font(.subheadline.weight(.bold))
|
||||||
|
.foregroundStyle(FlightTheme.textPrimary)
|
||||||
|
Text("total")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(FlightTheme.textTertiary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var stopsLabel: String {
|
||||||
|
switch connection.stopCount {
|
||||||
|
case 0: return "Direct"
|
||||||
|
case 1: return "1 stop"
|
||||||
|
default: return "\(connection.stopCount) stops"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var carriersLabel: String {
|
||||||
|
let codes = connection.carrierIatas
|
||||||
|
if codes.count == 1, let app = appendix?.airline(iata: codes[0])?.name {
|
||||||
|
return app
|
||||||
|
}
|
||||||
|
return codes.joined(separator: " · ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Layover
|
||||||
|
|
||||||
|
private func layoverMinutes(at index: Int) -> Int? {
|
||||||
|
guard index >= 1, index < connection.flights.count else { return nil }
|
||||||
|
let arr = connection.flights[index - 1].arrival.dateTime
|
||||||
|
let dep = connection.flights[index].departure.dateTime
|
||||||
|
let mins = Int(dep.timeIntervalSince(arr) / 60)
|
||||||
|
return mins > 0 ? mins : nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func layoverRow(minutes: Int, at iata: String) -> some View {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: "arrow.down")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(FlightTheme.textTertiary)
|
||||||
|
Text("Layover at \(iata) · \(formatDuration(minutes))")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(FlightTheme.textSecondary)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.leading, 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func formatDuration(_ minutes: Int) -> String {
|
||||||
|
let h = minutes / 60
|
||||||
|
let m = minutes % 60
|
||||||
|
if h > 0, m > 0 { return "\(h)h \(m)m" }
|
||||||
|
if h > 0 { return "\(h)h" }
|
||||||
|
return "\(m)m"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Single leg summary
|
||||||
|
|
||||||
|
private struct LegSummary: View {
|
||||||
|
let leg: RouteFlight
|
||||||
|
let appendix: RouteAppendix?
|
||||||
|
|
||||||
|
private static let timeFormatter: DateFormatter = {
|
||||||
|
let f = DateFormatter()
|
||||||
|
f.dateFormat = "HH:mm"
|
||||||
|
return f
|
||||||
|
}()
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(alignment: .center, spacing: 10) {
|
||||||
|
// Airline + flight number
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(leg.carrierIata)
|
||||||
|
.font(.caption.weight(.bold))
|
||||||
|
.foregroundStyle(FlightTheme.textPrimary)
|
||||||
|
Text("\(leg.flightNumber)")
|
||||||
|
.font(FlightTheme.flightNumber(11))
|
||||||
|
.foregroundStyle(FlightTheme.textSecondary)
|
||||||
|
}
|
||||||
|
.frame(width: 44, alignment: .leading)
|
||||||
|
|
||||||
|
// Times + airports
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
timeAirport(leg.departure)
|
||||||
|
Image(systemName: "arrow.right")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(FlightTheme.textTertiary)
|
||||||
|
timeAirport(leg.arrival)
|
||||||
|
}
|
||||||
|
if let aircraft = aircraftLabel {
|
||||||
|
Text(aircraft)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(FlightTheme.textTertiary)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(FlightTheme.textTertiary)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.background(FlightTheme.elevatedBackground)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
|
||||||
|
private func timeAirport(_ endpoint: RouteEndpoint) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 1) {
|
||||||
|
Text(Self.timeFormatter.string(from: endpoint.dateTime))
|
||||||
|
.font(.subheadline.weight(.semibold).monospaced())
|
||||||
|
.foregroundStyle(FlightTheme.textPrimary)
|
||||||
|
Text(endpoint.airportIata)
|
||||||
|
.font(.caption2.weight(.semibold))
|
||||||
|
.foregroundStyle(FlightTheme.textSecondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var aircraftLabel: String? {
|
||||||
|
guard let iata = leg.equipmentIata else { return nil }
|
||||||
|
return appendix?.equipment(iata: iata)?.name ?? iata
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Lightweight local-only IATA airport picker, backed by AirportDatabase.
|
||||||
|
/// No network — used by the route-explorer flows where we only need the
|
||||||
|
/// IATA code, not a flightconnections.com ID.
|
||||||
|
struct IATAAirportPicker: View {
|
||||||
|
let label: String
|
||||||
|
@Binding var selection: MapAirport?
|
||||||
|
let database: AirportDatabase
|
||||||
|
|
||||||
|
@State private var query: String = ""
|
||||||
|
@State private var isFocused: Bool = false
|
||||||
|
@FocusState private var fieldFocused: Bool
|
||||||
|
|
||||||
|
private var suggestions: [MapAirport] {
|
||||||
|
let q = query.trimmingCharacters(in: .whitespaces).uppercased()
|
||||||
|
guard q.count >= 2 else { return [] }
|
||||||
|
|
||||||
|
// Score: exact IATA match first, then prefix on IATA, then any name contains.
|
||||||
|
var exact: [MapAirport] = []
|
||||||
|
var iataPrefix: [MapAirport] = []
|
||||||
|
var nameMatch: [MapAirport] = []
|
||||||
|
|
||||||
|
for airport in database.airports {
|
||||||
|
if airport.iata == q {
|
||||||
|
exact.append(airport)
|
||||||
|
} else if airport.iata.hasPrefix(q) {
|
||||||
|
iataPrefix.append(airport)
|
||||||
|
} else if airport.name.uppercased().contains(q) {
|
||||||
|
nameMatch.append(airport)
|
||||||
|
}
|
||||||
|
if exact.count + iataPrefix.count + nameMatch.count > 30 { break }
|
||||||
|
}
|
||||||
|
return Array((exact + iataPrefix + nameMatch).prefix(10))
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack {
|
||||||
|
TextField(label, text: $query)
|
||||||
|
.textInputAutocapitalization(.characters)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
.focused($fieldFocused)
|
||||||
|
.onChange(of: fieldFocused) { _, new in
|
||||||
|
isFocused = new
|
||||||
|
}
|
||||||
|
.onChange(of: selection) { _, new in
|
||||||
|
if let new {
|
||||||
|
query = "\(new.iata) — \(new.name)"
|
||||||
|
fieldFocused = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if selection != nil {
|
||||||
|
Button {
|
||||||
|
selection = nil
|
||||||
|
query = ""
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "xmark.circle.fill")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 14)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
.background(FlightTheme.cardBackground)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
|
||||||
|
if isFocused, !suggestions.isEmpty, selection?.iata != suggestions.first?.iata {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
ForEach(suggestions) { airport in
|
||||||
|
Button {
|
||||||
|
selection = airport
|
||||||
|
query = "\(airport.iata) — \(airport.name)"
|
||||||
|
fieldFocused = false
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Text(airport.iata)
|
||||||
|
.font(FlightTheme.flightNumber(14))
|
||||||
|
.foregroundStyle(FlightTheme.accent)
|
||||||
|
.frame(width: 44, alignment: .leading)
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(airport.name)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(FlightTheme.textPrimary)
|
||||||
|
.lineLimit(1)
|
||||||
|
Text(airport.country)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(FlightTheme.textSecondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 14)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
|
||||||
|
if airport.id != suggestions.last?.id {
|
||||||
|
Divider().padding(.leading, 14)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.background(FlightTheme.cardBackground)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
.shadow(color: FlightTheme.cardShadow, radius: 6, y: 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,8 @@ import SwiftUI
|
|||||||
enum SearchRoute: Hashable {
|
enum SearchRoute: Hashable {
|
||||||
case destinations(Airport, Date, Bool)
|
case destinations(Airport, Date, Bool)
|
||||||
case routeDetail(Airport, Airport, Date)
|
case routeDetail(Airport, Airport, Date)
|
||||||
|
case routePlanner
|
||||||
|
case whereToGo
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ContentView: View {
|
struct ContentView: View {
|
||||||
@@ -10,15 +12,23 @@ struct ContentView: View {
|
|||||||
let database: AirportDatabase
|
let database: AirportDatabase
|
||||||
let loadService: AirlineLoadService
|
let loadService: AirlineLoadService
|
||||||
let favoritesManager: FavoritesManager
|
let favoritesManager: FavoritesManager
|
||||||
|
let routeExplorer: RouteExplorerClient
|
||||||
|
|
||||||
@State private var viewModel: SearchViewModel
|
@State private var viewModel: SearchViewModel
|
||||||
@State private var path = NavigationPath()
|
@State private var path = NavigationPath()
|
||||||
|
|
||||||
init(service: FlightService, database: AirportDatabase, loadService: AirlineLoadService = AirlineLoadService(), favoritesManager: FavoritesManager) {
|
init(
|
||||||
|
service: FlightService,
|
||||||
|
database: AirportDatabase,
|
||||||
|
loadService: AirlineLoadService = AirlineLoadService(),
|
||||||
|
favoritesManager: FavoritesManager,
|
||||||
|
routeExplorer: RouteExplorerClient = RouteExplorerClient()
|
||||||
|
) {
|
||||||
self.service = service
|
self.service = service
|
||||||
self.database = database
|
self.database = database
|
||||||
self.loadService = loadService
|
self.loadService = loadService
|
||||||
self.favoritesManager = favoritesManager
|
self.favoritesManager = favoritesManager
|
||||||
|
self.routeExplorer = routeExplorer
|
||||||
self._viewModel = State(initialValue: SearchViewModel(service: service, database: database))
|
self._viewModel = State(initialValue: SearchViewModel(service: service, database: database))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,6 +145,61 @@ struct ContentView: View {
|
|||||||
.disabled(!viewModel.canSearch)
|
.disabled(!viewModel.canSearch)
|
||||||
.opacity(viewModel.canSearch ? 1.0 : 0.5)
|
.opacity(viewModel.canSearch ? 1.0 : 0.5)
|
||||||
|
|
||||||
|
// MARK: - Multi-stop / Where-to-go entry points
|
||||||
|
VStack(spacing: 10) {
|
||||||
|
Button {
|
||||||
|
path.append(SearchRoute.routePlanner)
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Image(systemName: "arrow.triangle.branch")
|
||||||
|
.font(.title3)
|
||||||
|
.foregroundStyle(FlightTheme.accent)
|
||||||
|
.frame(width: 36, height: 36)
|
||||||
|
.background(FlightTheme.accent.opacity(0.12), in: RoundedRectangle(cornerRadius: 10))
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("Find Connections")
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
.foregroundStyle(FlightTheme.textPrimary)
|
||||||
|
Text("Direct + multi-stop A→B routing")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(FlightTheme.textSecondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(FlightTheme.textTertiary)
|
||||||
|
}
|
||||||
|
.flightCard()
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
path.append(SearchRoute.whereToGo)
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Image(systemName: "questionmark.diamond")
|
||||||
|
.font(.title3)
|
||||||
|
.foregroundStyle(FlightTheme.accent)
|
||||||
|
.frame(width: 36, height: 36)
|
||||||
|
.background(FlightTheme.accent.opacity(0.12), in: RoundedRectangle(cornerRadius: 10))
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("Where can I go?")
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
.foregroundStyle(FlightTheme.textPrimary)
|
||||||
|
Text("All departures in the next few hours")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(FlightTheme.textSecondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(FlightTheme.textTertiary)
|
||||||
|
}
|
||||||
|
.flightCard()
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Favorites
|
// MARK: - Favorites
|
||||||
if !favoritesManager.favorites.isEmpty {
|
if !favoritesManager.favorites.isEmpty {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
@@ -205,6 +270,18 @@ struct ContentView: View {
|
|||||||
loadService: loadService,
|
loadService: loadService,
|
||||||
favoritesManager: favoritesManager
|
favoritesManager: favoritesManager
|
||||||
)
|
)
|
||||||
|
case .routePlanner:
|
||||||
|
RoutePlannerView(
|
||||||
|
database: database,
|
||||||
|
client: routeExplorer,
|
||||||
|
loadService: loadService
|
||||||
|
)
|
||||||
|
case .whereToGo:
|
||||||
|
WhereToGoView(
|
||||||
|
database: database,
|
||||||
|
client: routeExplorer,
|
||||||
|
loadService: loadService
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,219 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Feature (a): pick origin + destination, find direct *and* multi-stop
|
||||||
|
/// itineraries via route-explorer.com `/route` with `maxStops`.
|
||||||
|
struct RoutePlannerView: View {
|
||||||
|
let database: AirportDatabase
|
||||||
|
let client: RouteExplorerClient
|
||||||
|
let loadService: AirlineLoadService
|
||||||
|
|
||||||
|
@State private var origin: MapAirport?
|
||||||
|
@State private var destination: MapAirport?
|
||||||
|
@State private var date: Date = Date()
|
||||||
|
@State private var maxStops: Int = 1
|
||||||
|
@State private var sortBy: RouteSortOption = .departureTime
|
||||||
|
@State private var includeInterline: Bool = false
|
||||||
|
|
||||||
|
@State private var isLoading: Bool = false
|
||||||
|
@State private var error: String?
|
||||||
|
@State private var connections: [RouteConnection] = []
|
||||||
|
@State private var appendix: RouteAppendix?
|
||||||
|
|
||||||
|
@State private var selectedFlight: FlightSchedule?
|
||||||
|
@State private var selectedDepCode: String = ""
|
||||||
|
@State private var selectedArrCode: String = ""
|
||||||
|
@State private var selectedDate: Date = Date()
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: FlightTheme.sectionSpacing) {
|
||||||
|
searchForm
|
||||||
|
resultsHeader
|
||||||
|
resultsList
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
}
|
||||||
|
.background(FlightTheme.background.ignoresSafeArea())
|
||||||
|
.navigationTitle("Connections")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.sheet(item: $selectedFlight) { flight in
|
||||||
|
FlightLoadDetailView(
|
||||||
|
schedule: flight,
|
||||||
|
departureCode: selectedDepCode,
|
||||||
|
arrivalCode: selectedArrCode,
|
||||||
|
date: selectedDate,
|
||||||
|
loadService: loadService
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Search form
|
||||||
|
|
||||||
|
private var searchForm: some View {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Label {
|
||||||
|
Text("FROM").font(FlightTheme.label()).tracking(1)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
} icon: {
|
||||||
|
Image(systemName: "airplane.departure").font(.caption).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
IATAAirportPicker(label: "Origin (IATA or city)", selection: $origin, database: database)
|
||||||
|
|
||||||
|
Label {
|
||||||
|
Text("TO").font(FlightTheme.label()).tracking(1).foregroundStyle(.secondary)
|
||||||
|
} icon: {
|
||||||
|
Image(systemName: "mappin.and.ellipse").font(.caption).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
IATAAirportPicker(label: "Destination (IATA or city)", selection: $destination, database: database)
|
||||||
|
}
|
||||||
|
.flightCard()
|
||||||
|
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Image(systemName: "calendar").foregroundStyle(FlightTheme.accent)
|
||||||
|
DatePicker("Travel Date", selection: $date, displayedComponents: .date)
|
||||||
|
.labelsHidden()
|
||||||
|
.datePickerStyle(.compact)
|
||||||
|
.tint(FlightTheme.accent)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.flightCard()
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
Text("MAX STOPS")
|
||||||
|
.font(FlightTheme.label())
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.tracking(1)
|
||||||
|
Picker("Max stops", selection: $maxStops) {
|
||||||
|
Text("Direct").tag(0)
|
||||||
|
Text("1 stop").tag(1)
|
||||||
|
Text("2 stops").tag(2)
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
|
||||||
|
Text("SORT BY")
|
||||||
|
.font(FlightTheme.label())
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.tracking(1)
|
||||||
|
.padding(.top, 4)
|
||||||
|
Picker("Sort by", selection: $sortBy) {
|
||||||
|
ForEach(RouteSortOption.allCases, id: \.self) { option in
|
||||||
|
Text(option.label).tag(option)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
|
||||||
|
Toggle(isOn: $includeInterline) {
|
||||||
|
Text("Interline carriers only")
|
||||||
|
.font(.subheadline)
|
||||||
|
}
|
||||||
|
.padding(.top, 4)
|
||||||
|
.tint(FlightTheme.accent)
|
||||||
|
}
|
||||||
|
.flightCard()
|
||||||
|
|
||||||
|
Button {
|
||||||
|
Task { await runSearch() }
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
if isLoading {
|
||||||
|
ProgressView().tint(.white)
|
||||||
|
} else {
|
||||||
|
Image(systemName: "magnifyingglass")
|
||||||
|
}
|
||||||
|
Text(isLoading ? "Searching..." : "Search Routes")
|
||||||
|
.fontWeight(.bold)
|
||||||
|
}
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(height: 50)
|
||||||
|
.background(
|
||||||
|
LinearGradient(
|
||||||
|
colors: [FlightTheme.accent, FlightTheme.accentLight],
|
||||||
|
startPoint: .leading,
|
||||||
|
endPoint: .trailing
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
}
|
||||||
|
.disabled(!canSearch || isLoading)
|
||||||
|
.opacity(canSearch && !isLoading ? 1.0 : 0.5)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Results
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var resultsHeader: some View {
|
||||||
|
if let error {
|
||||||
|
ContentUnavailableView {
|
||||||
|
Label("Error", systemImage: "exclamationmark.triangle")
|
||||||
|
} description: {
|
||||||
|
Text(error)
|
||||||
|
} actions: {
|
||||||
|
Button("Retry") {
|
||||||
|
Task { await runSearch() }
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.tint(FlightTheme.accent)
|
||||||
|
}
|
||||||
|
} else if !connections.isEmpty {
|
||||||
|
HStack {
|
||||||
|
Text("\(connections.count) itinerar\(connections.count == 1 ? "y" : "ies")")
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
.foregroundStyle(FlightTheme.textPrimary)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var resultsList: some View {
|
||||||
|
ForEach(connections) { connection in
|
||||||
|
ConnectionRow(connection: connection, appendix: appendix) { leg in
|
||||||
|
openLegDetail(leg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private var canSearch: Bool {
|
||||||
|
origin != nil && destination != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func runSearch() async {
|
||||||
|
guard let origin, let destination else { return }
|
||||||
|
isLoading = true
|
||||||
|
error = nil
|
||||||
|
connections = []
|
||||||
|
|
||||||
|
do {
|
||||||
|
let result = try await client.searchRoutes(
|
||||||
|
from: origin.iata,
|
||||||
|
to: destination.iata,
|
||||||
|
date: date,
|
||||||
|
maxStops: maxStops,
|
||||||
|
includeInterline: includeInterline,
|
||||||
|
sortBy: sortBy,
|
||||||
|
limit: 100
|
||||||
|
)
|
||||||
|
self.connections = result.connections
|
||||||
|
self.appendix = result.appendix
|
||||||
|
if result.connections.isEmpty {
|
||||||
|
self.error = "No routes found from \(origin.iata) to \(destination.iata) on this date."
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
self.error = (error as? RouteExplorerClient.ClientError)?.errorDescription ?? error.localizedDescription
|
||||||
|
}
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func openLegDetail(_ leg: RouteFlight) {
|
||||||
|
selectedDepCode = leg.departure.airportIata
|
||||||
|
selectedArrCode = leg.arrival.airportIata
|
||||||
|
selectedDate = leg.departure.dateTime
|
||||||
|
selectedFlight = leg.toFlightSchedule(appendix: appendix, on: leg.departure.dateTime)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,348 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Feature (b): "Where tf do I go" — pick an airport and see all departures
|
||||||
|
/// in the next N hours, ranked by departure time.
|
||||||
|
struct WhereToGoView: View {
|
||||||
|
let database: AirportDatabase
|
||||||
|
let client: RouteExplorerClient
|
||||||
|
let loadService: AirlineLoadService
|
||||||
|
|
||||||
|
@State private var origin: MapAirport?
|
||||||
|
@State private var windowHours: Int = 6
|
||||||
|
@State private var referenceDate: Date = Date()
|
||||||
|
|
||||||
|
@State private var isLoading: Bool = false
|
||||||
|
@State private var error: String?
|
||||||
|
@State private var connections: [RouteConnection] = []
|
||||||
|
@State private var appendix: RouteAppendix?
|
||||||
|
|
||||||
|
@State private var selectedFlight: FlightSchedule?
|
||||||
|
@State private var selectedDepCode: String = ""
|
||||||
|
@State private var selectedArrCode: String = ""
|
||||||
|
@State private var selectedDate: Date = Date()
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: FlightTheme.sectionSpacing) {
|
||||||
|
pickerForm
|
||||||
|
resultsHeader
|
||||||
|
resultsList
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
}
|
||||||
|
.background(FlightTheme.background.ignoresSafeArea())
|
||||||
|
.navigationTitle("Where can I go?")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.sheet(item: $selectedFlight) { flight in
|
||||||
|
FlightLoadDetailView(
|
||||||
|
schedule: flight,
|
||||||
|
departureCode: selectedDepCode,
|
||||||
|
arrivalCode: selectedArrCode,
|
||||||
|
date: selectedDate,
|
||||||
|
loadService: loadService
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Picker form
|
||||||
|
|
||||||
|
private var pickerForm: some View {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Label {
|
||||||
|
Text("FROM").font(FlightTheme.label()).tracking(1)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
} icon: {
|
||||||
|
Image(systemName: "airplane.departure").font(.caption).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
IATAAirportPicker(label: "Airport (IATA or city)", selection: $origin, database: database)
|
||||||
|
}
|
||||||
|
.flightCard()
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
Text("DEPARTING WITHIN")
|
||||||
|
.font(FlightTheme.label())
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.tracking(1)
|
||||||
|
Picker("Window", selection: $windowHours) {
|
||||||
|
Text("2h").tag(2)
|
||||||
|
Text("4h").tag(4)
|
||||||
|
Text("6h").tag(6)
|
||||||
|
Text("12h").tag(12)
|
||||||
|
Text("24h").tag(24)
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Image(systemName: "calendar")
|
||||||
|
.foregroundStyle(FlightTheme.accent)
|
||||||
|
.font(.body)
|
||||||
|
DatePicker("From", selection: $referenceDate, displayedComponents: [.date, .hourAndMinute])
|
||||||
|
.labelsHidden()
|
||||||
|
.datePickerStyle(.compact)
|
||||||
|
.tint(FlightTheme.accent)
|
||||||
|
Spacer()
|
||||||
|
Button("Now") {
|
||||||
|
referenceDate = Date()
|
||||||
|
}
|
||||||
|
.font(.caption.weight(.semibold))
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.tint(FlightTheme.accent.opacity(0.2))
|
||||||
|
.foregroundStyle(FlightTheme.accent)
|
||||||
|
}
|
||||||
|
.padding(.top, 6)
|
||||||
|
}
|
||||||
|
.flightCard()
|
||||||
|
|
||||||
|
Button {
|
||||||
|
Task { await runSearch() }
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
if isLoading {
|
||||||
|
ProgressView().tint(.white)
|
||||||
|
} else {
|
||||||
|
Image(systemName: "questionmark.diamond")
|
||||||
|
}
|
||||||
|
Text(isLoading ? "Loading..." : "Where can I go?")
|
||||||
|
.fontWeight(.bold)
|
||||||
|
}
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(height: 50)
|
||||||
|
.background(
|
||||||
|
LinearGradient(
|
||||||
|
colors: [FlightTheme.accent, FlightTheme.accentLight],
|
||||||
|
startPoint: .leading,
|
||||||
|
endPoint: .trailing
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
}
|
||||||
|
.disabled(origin == nil || isLoading)
|
||||||
|
.opacity((origin != nil && !isLoading) ? 1.0 : 0.5)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Results
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var resultsHeader: some View {
|
||||||
|
if let error {
|
||||||
|
ContentUnavailableView {
|
||||||
|
Label("Error", systemImage: "exclamationmark.triangle")
|
||||||
|
} description: {
|
||||||
|
Text(error)
|
||||||
|
} actions: {
|
||||||
|
Button("Retry") {
|
||||||
|
Task { await runSearch() }
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.tint(FlightTheme.accent)
|
||||||
|
}
|
||||||
|
} else if !filteredFlights.isEmpty {
|
||||||
|
HStack {
|
||||||
|
Text("\(filteredFlights.count) departure\(filteredFlights.count == 1 ? "" : "s")")
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
.foregroundStyle(FlightTheme.textPrimary)
|
||||||
|
Spacer()
|
||||||
|
Text(windowDescription)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(FlightTheme.textSecondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var resultsList: some View {
|
||||||
|
ForEach(filteredFlights, id: \.id) { leg in
|
||||||
|
Button {
|
||||||
|
openLegDetail(leg)
|
||||||
|
} label: {
|
||||||
|
DepartureLegRow(leg: leg, appendix: appendix, referenceDate: referenceDate)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Filtering
|
||||||
|
|
||||||
|
/// Flatten connections (each is a single leg here since we requested
|
||||||
|
/// /departures with maxStops:0) and filter by departure-time window.
|
||||||
|
private var filteredFlights: [RouteFlight] {
|
||||||
|
let windowEnd = referenceDate.addingTimeInterval(TimeInterval(windowHours * 3600))
|
||||||
|
let allLegs = connections.flatMap { $0.flights }
|
||||||
|
|
||||||
|
return allLegs
|
||||||
|
.filter { leg in
|
||||||
|
let dep = leg.departure.dateTime
|
||||||
|
return dep >= referenceDate && dep <= windowEnd
|
||||||
|
}
|
||||||
|
.sorted { $0.departure.dateTime < $1.departure.dateTime }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var windowDescription: String {
|
||||||
|
"next \(windowHours)h"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func runSearch() async {
|
||||||
|
guard let origin else { return }
|
||||||
|
isLoading = true
|
||||||
|
error = nil
|
||||||
|
connections = []
|
||||||
|
appendix = nil
|
||||||
|
|
||||||
|
do {
|
||||||
|
// /departures returns one connection per single-leg flight when
|
||||||
|
// maxStops:0. We pass the calendar date that includes our window;
|
||||||
|
// if the window crosses midnight we'll fall back to also fetching
|
||||||
|
// the next day in a follow-up call.
|
||||||
|
let windowEnd = referenceDate.addingTimeInterval(TimeInterval(windowHours * 3600))
|
||||||
|
var allConnections: [RouteConnection] = []
|
||||||
|
var capturedAppendix: RouteAppendix?
|
||||||
|
|
||||||
|
let day1 = try await client.searchDepartures(from: origin.iata, date: referenceDate, maxStops: 0, limit: 200)
|
||||||
|
allConnections.append(contentsOf: day1.connections)
|
||||||
|
capturedAppendix = day1.appendix
|
||||||
|
|
||||||
|
// Cross-midnight: fetch next day too.
|
||||||
|
let cal = Calendar.current
|
||||||
|
if !cal.isDate(referenceDate, inSameDayAs: windowEnd) {
|
||||||
|
let day2 = try await client.searchDepartures(from: origin.iata, date: windowEnd, maxStops: 0, limit: 200)
|
||||||
|
allConnections.append(contentsOf: day2.connections)
|
||||||
|
if capturedAppendix == nil { capturedAppendix = day2.appendix }
|
||||||
|
}
|
||||||
|
|
||||||
|
self.connections = allConnections
|
||||||
|
self.appendix = capturedAppendix
|
||||||
|
if filteredFlights.isEmpty {
|
||||||
|
self.error = "Nothing leaving \(origin.iata) in the next \(windowHours)h."
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
self.error = (error as? RouteExplorerClient.ClientError)?.errorDescription ?? error.localizedDescription
|
||||||
|
}
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func openLegDetail(_ leg: RouteFlight) {
|
||||||
|
selectedDepCode = leg.departure.airportIata
|
||||||
|
selectedArrCode = leg.arrival.airportIata
|
||||||
|
selectedDate = leg.departure.dateTime
|
||||||
|
selectedFlight = leg.toFlightSchedule(appendix: appendix, on: leg.departure.dateTime)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Departure leg row
|
||||||
|
|
||||||
|
private struct DepartureLegRow: View {
|
||||||
|
let leg: RouteFlight
|
||||||
|
let appendix: RouteAppendix?
|
||||||
|
let referenceDate: Date
|
||||||
|
|
||||||
|
private static let timeFormatter: DateFormatter = {
|
||||||
|
let f = DateFormatter()
|
||||||
|
f.dateFormat = "HH:mm"
|
||||||
|
return f
|
||||||
|
}()
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
HStack(alignment: .center, spacing: 10) {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("\(leg.carrierIata) \(leg.flightNumber)")
|
||||||
|
.font(.subheadline.weight(.bold))
|
||||||
|
.foregroundStyle(FlightTheme.textPrimary)
|
||||||
|
Text(airlineName)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(FlightTheme.textSecondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
VStack(alignment: .trailing, spacing: 2) {
|
||||||
|
Text(Self.timeFormatter.string(from: leg.departure.dateTime))
|
||||||
|
.font(.subheadline.weight(.semibold).monospaced())
|
||||||
|
.foregroundStyle(FlightTheme.textPrimary)
|
||||||
|
Text(leavesIn)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(leavesInColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Text(leg.departure.airportIata)
|
||||||
|
.font(FlightTheme.airportCode(20))
|
||||||
|
Image(systemName: "airplane")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(FlightTheme.textTertiary)
|
||||||
|
.rotationEffect(.degrees(-45))
|
||||||
|
Text(leg.arrival.airportIata)
|
||||||
|
.font(FlightTheme.airportCode(20))
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
if let aircraft = aircraftLabel {
|
||||||
|
Text(aircraft)
|
||||||
|
.font(FlightTheme.label(11))
|
||||||
|
.foregroundStyle(FlightTheme.textSecondary)
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 3)
|
||||||
|
.background(Color(.quaternarySystemFill), in: Capsule())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
if let total = leg.totalSeats {
|
||||||
|
metaPill("\(total) seats")
|
||||||
|
}
|
||||||
|
if let f = leg.classes?.first?.seats, f > 0 { metaPill("F·\(f)") }
|
||||||
|
if let j = leg.classes?.business?.seats, j > 0 { metaPill("J·\(j)") }
|
||||||
|
if let w = leg.classes?.premiumEconomy?.seats, w > 0 { metaPill("W·\(w)") }
|
||||||
|
if let y = leg.classes?.economy?.seats, y > 0 { metaPill("Y·\(y)") }
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(FlightTheme.textTertiary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.flightCard()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func metaPill(_ text: String) -> some View {
|
||||||
|
Text(text)
|
||||||
|
.font(.caption2.monospaced())
|
||||||
|
.foregroundStyle(FlightTheme.textSecondary)
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 3)
|
||||||
|
.background(FlightTheme.accent.opacity(0.10), in: Capsule())
|
||||||
|
}
|
||||||
|
|
||||||
|
private var airlineName: String {
|
||||||
|
appendix?.airline(iata: leg.carrierIata)?.name ?? leg.carrierIata
|
||||||
|
}
|
||||||
|
|
||||||
|
private var aircraftLabel: String? {
|
||||||
|
guard let iata = leg.equipmentIata else { return nil }
|
||||||
|
return appendix?.equipment(iata: iata)?.name ?? iata
|
||||||
|
}
|
||||||
|
|
||||||
|
private var leavesIn: String {
|
||||||
|
let mins = Int(leg.departure.dateTime.timeIntervalSince(referenceDate) / 60)
|
||||||
|
if mins < 0 { return "departed" }
|
||||||
|
if mins < 60 { return "in \(mins)m" }
|
||||||
|
let h = mins / 60
|
||||||
|
let m = mins % 60
|
||||||
|
if m == 0 { return "in \(h)h" }
|
||||||
|
return "in \(h)h \(m)m"
|
||||||
|
}
|
||||||
|
|
||||||
|
private var leavesInColor: Color {
|
||||||
|
let mins = Int(leg.departure.dateTime.timeIntervalSince(referenceDate) / 60)
|
||||||
|
switch mins {
|
||||||
|
case ..<30: return FlightTheme.cancelled // hurry
|
||||||
|
case 30..<90: return FlightTheme.delayed // soon
|
||||||
|
default: return FlightTheme.textSecondary
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
# Route Explorer API surface (route-explorer.com)
|
||||||
|
|
||||||
|
Captured 2026-04-25 from https://route-explorer.com (StaffTraveler's public web app).
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Browser ──GET /api/token──> Vercel function (returns HMAC token)
|
||||||
|
Browser ──POST /api/flight-search { X-API-Token } ──> Vercel function ──> upstream tRPC
|
||||||
|
Browser ──GET /api/weather { X-API-Token } ────────> Vercel function ──> open-meteo
|
||||||
|
Browser ──GET *.public.blob.vercel-storage.com/data/routes/{IATA}.json ──> static CDN, no auth
|
||||||
|
```
|
||||||
|
|
||||||
|
The browser **never** calls `api.stafftraveler.com` directly despite it being in the page CSP. Everything routes through `route-explorer.com/api/*` Vercel functions, which validate `X-API-Token` and proxy upstream. Upstream uses tRPC with SuperJSON envelopes (`{ json: {...} }`).
|
||||||
|
|
||||||
|
## Auth: `/api/token`
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET https://route-explorer.com/api/token
|
||||||
|
```
|
||||||
|
|
||||||
|
Response:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"token": "1777091466.01c0355b2bbab0cacfd37cf3ebb9ce1f.f3fcb79c6f60c7cfaa1750000c133a2c7add44973329a9d74429a1622ab4cdc2",
|
||||||
|
"countryCode": "US"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- Format: `{unixSeconds}.{16-byte-id-hex}.{32-byte-hmac-hex}`
|
||||||
|
- IP rate-limited: `X-RateLimit-Limit: 10` per window (X-RateLimit-Reset returns next window unix time).
|
||||||
|
- No login, no cookie required. `credentials: include` is harmless.
|
||||||
|
- TTL not measured; tokens issued seconds apart all worked. Refresh per session.
|
||||||
|
|
||||||
|
Pass as `X-API-Token: <token>` to `/api/flight-search` and `/api/weather`.
|
||||||
|
|
||||||
|
## `/api/flight-search` (POST)
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/flight-search
|
||||||
|
Content-Type: application/json
|
||||||
|
X-API-Token: <token>
|
||||||
|
|
||||||
|
{ "endpoint": "/<one of allowed>", "body": { "json": { ... params ... } } }
|
||||||
|
```
|
||||||
|
|
||||||
|
Allowed values for `endpoint`: **`/route`, `/route-batch`, `/flight`, `/departures`, `/schedule`**.
|
||||||
|
Anything else returns 400 with `{"error":"Invalid endpoint '...'. Allowed: ..."}` — proxy enforces an allowlist.
|
||||||
|
|
||||||
|
Responses are tRPC SuperJSON: `{ "json": { ... } }`. Errors include a `code`/`status` and a `data.issues[]` array straight from a Zod schema, which leaks the parameter names.
|
||||||
|
|
||||||
|
### `/schedule`
|
||||||
|
|
||||||
|
Required: `carrierCode` (str), `flightNumber` (num), `startDate` (YYYY-MM-DD), `endDate` (YYYY-MM-DD).
|
||||||
|
Optional: `limit`, `includeAppendix`.
|
||||||
|
|
||||||
|
Returns one row per operating day in the range, with full per-cabin seat counts and equipment.
|
||||||
|
|
||||||
|
Sample response: `route_explorer_captures/schedule_AA2178.json`
|
||||||
|
|
||||||
|
### `/route` — also does multi-leg connection finding
|
||||||
|
|
||||||
|
Required: `departureAirportIata`, `arrivalAirportIata`, `departureDates` (string[]).
|
||||||
|
|
||||||
|
Optional (observed in real Connections-tab traffic):
|
||||||
|
- `maxStops` — `0` for direct only, `1` or `2` for multi-leg search. Server returns already-joined itineraries; no client-side join needed.
|
||||||
|
- `sortBy` — `"departure_time"` | `"duration"` (and likely `"arrival_time"`, `"score"`, `"stops"`)
|
||||||
|
- `includeInterline` — boolean. When true, restricts/highlights carriers with interline non-rev agreements.
|
||||||
|
- `limit` — max results
|
||||||
|
- `includeAppendix` — boolean. When true, the response includes an `appendix.{airports,airlines,equipment}` block with reference metadata for everything in `connections[]`.
|
||||||
|
|
||||||
|
Real Connections-tab request body:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"endpoint": "/route",
|
||||||
|
"body": { "json": {
|
||||||
|
"departureAirportIata": "DFW",
|
||||||
|
"arrivalAirportIata": "KOA",
|
||||||
|
"departureDates": ["2026-04-28"],
|
||||||
|
"maxStops": 0,
|
||||||
|
"sortBy": "departure_time",
|
||||||
|
"includeInterline": false,
|
||||||
|
"limit": 100,
|
||||||
|
"includeAppendix": true
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Each entry in `connections[]`:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"durationMinutes": 736,
|
||||||
|
"score": 1636,
|
||||||
|
"flights": [
|
||||||
|
{ /* leg 1: DFW→SEA, full row shape (see below) */ },
|
||||||
|
{ /* leg 2: SEA→KOA, full row shape */ }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
With `maxStops > 0` the server enforces layover validity and chains legs in time order. `score` is the server's ranking metric (lower = better; appears to combine duration + stops penalty).
|
||||||
|
|
||||||
|
Samples:
|
||||||
|
- `route_explorer_captures/route_DFW_LAS.json` — direct only, all DFW→LAS departures on 2026-05-01.
|
||||||
|
- `route_explorer_captures/route_DFW_KOA_1stop.json` — 1-stop itineraries DFW→KOA on 2026-04-28.
|
||||||
|
- `route_explorer_captures/route_LBB_KOA_2stop.json` — 2-stop itineraries LBB→KOA on 2026-04-28.
|
||||||
|
|
||||||
|
### `/route-batch`
|
||||||
|
|
||||||
|
Not needed for typical use cases — `/route` with `maxStops > 0` does multi-leg directly. Schema for `/route-batch` not fully reverse-engineered (proxy validates `body.base` field directly, outside the SuperJSON envelope, and rejected every shape probed). Skip unless a specific need arises.
|
||||||
|
|
||||||
|
### `/flight`
|
||||||
|
|
||||||
|
Required: `carrierCode` **OR** `carrierIata`, `flightNumber`, `departureDates`.
|
||||||
|
|
||||||
|
Returns flights for a specific flight number on specific dates. Smaller / sharper than `/schedule`.
|
||||||
|
|
||||||
|
Sample: `route_explorer_captures/flight_AA2178.json`
|
||||||
|
|
||||||
|
### `/departures`
|
||||||
|
|
||||||
|
Required: `departureAirportIata`, `departureDates`.
|
||||||
|
|
||||||
|
All departures from an airport on the given date(s).
|
||||||
|
|
||||||
|
Sample: `route_explorer_captures/departures_DFW.json` — 68KB.
|
||||||
|
|
||||||
|
## Common flight row shape (returned by `/route`, `/flight`, `/departures`, `/schedule`)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"flightNumber": 2178,
|
||||||
|
"flightSuffix": null,
|
||||||
|
"departure": { "airportIata": "DFW", "dateTime": "2026-04-24T16:45:00-05:00", "terminal": "0" },
|
||||||
|
"arrival": { "airportIata": "LAS", "dateTime": "2026-04-24T17:46:00-07:00", "terminal": "1" },
|
||||||
|
"durationMinutes": 181,
|
||||||
|
"equipmentIata": "32Q",
|
||||||
|
"serviceType": "J",
|
||||||
|
"isCodeshare": false,
|
||||||
|
"stops": 0,
|
||||||
|
"stopCodes": null,
|
||||||
|
"totalSeats": 196,
|
||||||
|
"classes": {
|
||||||
|
"first": { "seats": 0, "mealCodes": [] },
|
||||||
|
"business": { "seats": 20, "mealCodes": ["D"] },
|
||||||
|
"premiumEconomy": { "seats": 0, "mealCodes": [] },
|
||||||
|
"economy": { "seats": 176, "mealCodes": ["R","D"] }
|
||||||
|
},
|
||||||
|
"inFlightService": [12,18,22,23,24,26,27,28,29,31],
|
||||||
|
"id": "AA_2178_DFW_2026_04_24",
|
||||||
|
"carrierIata": "AA",
|
||||||
|
"carrierIcao": "AAL",
|
||||||
|
"isWetlease": false,
|
||||||
|
"codeshares": []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
dateTime values are local times with offset; stable IDs are `{carrier}_{flight}_{origin}_{YYYY}_{MM}_{DD}`.
|
||||||
|
|
||||||
|
## `/api/weather` (GET)
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/weather?endpoint=current&q=iata:LAS&alerts=yes
|
||||||
|
X-API-Token: <token>
|
||||||
|
```
|
||||||
|
|
||||||
|
Wraps a weather provider (likely WeatherAPI.com — `q=iata:LAS` is their syntax). Schema not fully probed.
|
||||||
|
|
||||||
|
## Public route blobs (no auth at all)
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET https://g80l6xxwjkrjoai7.public.blob.vercel-storage.com/data/routes/{IATA}.json
|
||||||
|
```
|
||||||
|
|
||||||
|
One precomputed file per airport with weekly aggregate route data. Examples:
|
||||||
|
|
||||||
|
- DFW.json — 271 destinations, 34 airlines, 50,880 weekly flights, 7.15M weekly seats
|
||||||
|
- JFK.json — 200 destinations, 75 airlines, 21,611 weekly flights, 3.69M weekly seats (2.5MB file)
|
||||||
|
|
||||||
|
Schema (top level): `airport`, `updated` (ISO timestamp), `stats { destinations, airlines, countries, totalWeeklyFlights, totalWeeklySeats, avgDistance, seasonalRoutes }`, `routes[]`.
|
||||||
|
|
||||||
|
`routes[]` entry shape:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"dest": "ORD",
|
||||||
|
"airlines": ["AA","F9","NK","UA"],
|
||||||
|
"freq": 941, // weekly flights aggregated across carriers
|
||||||
|
"dist": 1291, // miles
|
||||||
|
"totalSeats": 163285, // weekly
|
||||||
|
"avgDuration": 155, // minutes
|
||||||
|
"equipment": ["319","320","321","32N","32Q","738","739","73G","788","7M8","7M9","E70","E7W"],
|
||||||
|
"bodyTypes": ["N","W"], // narrow/wide
|
||||||
|
"isSeasonal": true,
|
||||||
|
"mealService": "S", // single letter code
|
||||||
|
"effectiveDates": [{"from":"20270106","to":"20270210"}, ...],
|
||||||
|
"daysOfWeek": "1234567" // 1=Mon .. 7=Sun
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Updated approx weekly (DFW & JFK both stamped 2026-04-20).
|
||||||
|
|
||||||
|
## Other domains observed
|
||||||
|
|
||||||
|
- `https://emrldtp.com/{entrypoint_config,collect}` — Emerald Travel Tech analytics. Cookie consent banner.
|
||||||
|
- `https://sentry.avs.io/...` — self-hosted Sentry (DSN `1c30377da...@sentry.avs.io/20`).
|
||||||
|
- `https://images.stafftraveler.com` — image CDN (logos, etc.).
|
||||||
|
- `https://api.mapbox.com` — Mapbox dark-v11/light-v11 styles, public token `pk.eyJ1Ijoic3RhZmZ0cmF2ZWxlciIsImEiOiJjbWxyNzVqMzgwN2xhM2ZzNGEzaHVkcDY2In0...`.
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,67 @@
|
|||||||
|
{
|
||||||
|
"json": {
|
||||||
|
"flights": [
|
||||||
|
{
|
||||||
|
"flightNumber": 2178,
|
||||||
|
"flightSuffix": null,
|
||||||
|
"departure": {
|
||||||
|
"airportIata": "DFW",
|
||||||
|
"dateTime": "2026-05-01T16:45:00-05:00",
|
||||||
|
"terminal": "0"
|
||||||
|
},
|
||||||
|
"arrival": {
|
||||||
|
"airportIata": "LAS",
|
||||||
|
"dateTime": "2026-05-01T17:46:00-07:00",
|
||||||
|
"terminal": "1"
|
||||||
|
},
|
||||||
|
"durationMinutes": 181,
|
||||||
|
"equipmentIata": "32Q",
|
||||||
|
"serviceType": "J",
|
||||||
|
"isCodeshare": false,
|
||||||
|
"stops": 0,
|
||||||
|
"stopCodes": null,
|
||||||
|
"totalSeats": 196,
|
||||||
|
"classes": {
|
||||||
|
"first": {
|
||||||
|
"seats": 0,
|
||||||
|
"mealCodes": []
|
||||||
|
},
|
||||||
|
"business": {
|
||||||
|
"seats": 20,
|
||||||
|
"mealCodes": [
|
||||||
|
"D"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"premiumEconomy": {
|
||||||
|
"seats": 0,
|
||||||
|
"mealCodes": []
|
||||||
|
},
|
||||||
|
"economy": {
|
||||||
|
"seats": 176,
|
||||||
|
"mealCodes": [
|
||||||
|
"R",
|
||||||
|
"D"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"inFlightService": [
|
||||||
|
12,
|
||||||
|
18,
|
||||||
|
22,
|
||||||
|
23,
|
||||||
|
24,
|
||||||
|
26,
|
||||||
|
27,
|
||||||
|
28,
|
||||||
|
29,
|
||||||
|
31
|
||||||
|
],
|
||||||
|
"id": "AA_2178_DFW_2026_05_01",
|
||||||
|
"carrierIata": "AA",
|
||||||
|
"carrierIcao": "AAL",
|
||||||
|
"isWetlease": false,
|
||||||
|
"codeshares": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,773 @@
|
|||||||
|
{
|
||||||
|
"json": {
|
||||||
|
"connections": [
|
||||||
|
{
|
||||||
|
"durationMinutes": 736,
|
||||||
|
"score": 1636,
|
||||||
|
"flights": [
|
||||||
|
{
|
||||||
|
"flightNumber": 543,
|
||||||
|
"flightSuffix": null,
|
||||||
|
"departure": {
|
||||||
|
"airportIata": "DFW",
|
||||||
|
"dateTime": "2026-04-28T06:20:00-05:00",
|
||||||
|
"terminal": "E"
|
||||||
|
},
|
||||||
|
"arrival": {
|
||||||
|
"airportIata": "SEA",
|
||||||
|
"dateTime": "2026-04-28T08:52:00-07:00",
|
||||||
|
"terminal": null
|
||||||
|
},
|
||||||
|
"durationMinutes": 272,
|
||||||
|
"equipmentIata": "73J",
|
||||||
|
"serviceType": "J",
|
||||||
|
"isCodeshare": false,
|
||||||
|
"stops": 0,
|
||||||
|
"stopCodes": null,
|
||||||
|
"totalSeats": 178,
|
||||||
|
"classes": {
|
||||||
|
"first": {
|
||||||
|
"seats": 0,
|
||||||
|
"mealCodes": []
|
||||||
|
},
|
||||||
|
"business": {
|
||||||
|
"seats": 16,
|
||||||
|
"mealCodes": [
|
||||||
|
"B"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"premiumEconomy": {
|
||||||
|
"seats": 0,
|
||||||
|
"mealCodes": []
|
||||||
|
},
|
||||||
|
"economy": {
|
||||||
|
"seats": 162,
|
||||||
|
"mealCodes": [
|
||||||
|
"B"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"inFlightService": [
|
||||||
|
3,
|
||||||
|
12,
|
||||||
|
18
|
||||||
|
],
|
||||||
|
"id": "AS_543_DFW_2026_04_28",
|
||||||
|
"carrierIata": "AS",
|
||||||
|
"carrierIcao": "ASA",
|
||||||
|
"isWetlease": false,
|
||||||
|
"codeshares": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"flightNumber": 204,
|
||||||
|
"flightSuffix": null,
|
||||||
|
"departure": {
|
||||||
|
"airportIata": "SEA",
|
||||||
|
"dateTime": "2026-04-28T10:04:00-07:00",
|
||||||
|
"terminal": null
|
||||||
|
},
|
||||||
|
"arrival": {
|
||||||
|
"airportIata": "KOA",
|
||||||
|
"dateTime": "2026-04-28T13:36:00-10:00",
|
||||||
|
"terminal": null
|
||||||
|
},
|
||||||
|
"durationMinutes": 392,
|
||||||
|
"equipmentIata": "7M9",
|
||||||
|
"serviceType": "J",
|
||||||
|
"isCodeshare": false,
|
||||||
|
"stops": 0,
|
||||||
|
"stopCodes": null,
|
||||||
|
"totalSeats": 178,
|
||||||
|
"classes": {
|
||||||
|
"first": {
|
||||||
|
"seats": 0,
|
||||||
|
"mealCodes": []
|
||||||
|
},
|
||||||
|
"business": {
|
||||||
|
"seats": 16,
|
||||||
|
"mealCodes": [
|
||||||
|
"L"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"premiumEconomy": {
|
||||||
|
"seats": 0,
|
||||||
|
"mealCodes": []
|
||||||
|
},
|
||||||
|
"economy": {
|
||||||
|
"seats": 162,
|
||||||
|
"mealCodes": [
|
||||||
|
"L"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"inFlightService": [
|
||||||
|
3,
|
||||||
|
12,
|
||||||
|
18
|
||||||
|
],
|
||||||
|
"id": "AS_204_SEA_2026_04_28",
|
||||||
|
"carrierIata": "AS",
|
||||||
|
"carrierIcao": "ASA",
|
||||||
|
"isWetlease": false,
|
||||||
|
"codeshares": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"durationMinutes": 736,
|
||||||
|
"score": 1736,
|
||||||
|
"flights": [
|
||||||
|
{
|
||||||
|
"flightNumber": 474,
|
||||||
|
"flightSuffix": null,
|
||||||
|
"departure": {
|
||||||
|
"airportIata": "DFW",
|
||||||
|
"dateTime": "2026-04-28T07:10:00-05:00",
|
||||||
|
"terminal": "0"
|
||||||
|
},
|
||||||
|
"arrival": {
|
||||||
|
"airportIata": "PHX",
|
||||||
|
"dateTime": "2026-04-28T07:54:00-07:00",
|
||||||
|
"terminal": "4"
|
||||||
|
},
|
||||||
|
"durationMinutes": 164,
|
||||||
|
"equipmentIata": "321",
|
||||||
|
"serviceType": "J",
|
||||||
|
"isCodeshare": false,
|
||||||
|
"stops": 0,
|
||||||
|
"stopCodes": null,
|
||||||
|
"totalSeats": 190,
|
||||||
|
"classes": {
|
||||||
|
"first": {
|
||||||
|
"seats": 0,
|
||||||
|
"mealCodes": []
|
||||||
|
},
|
||||||
|
"business": {
|
||||||
|
"seats": 20,
|
||||||
|
"mealCodes": []
|
||||||
|
},
|
||||||
|
"premiumEconomy": {
|
||||||
|
"seats": 0,
|
||||||
|
"mealCodes": []
|
||||||
|
},
|
||||||
|
"economy": {
|
||||||
|
"seats": 170,
|
||||||
|
"mealCodes": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"inFlightService": [
|
||||||
|
4,
|
||||||
|
12,
|
||||||
|
18,
|
||||||
|
22,
|
||||||
|
23,
|
||||||
|
26,
|
||||||
|
27,
|
||||||
|
28,
|
||||||
|
31
|
||||||
|
],
|
||||||
|
"id": "AA_474_DFW_2026_04_28",
|
||||||
|
"carrierIata": "AA",
|
||||||
|
"carrierIcao": "AAL",
|
||||||
|
"isWetlease": false,
|
||||||
|
"codeshares": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"flightNumber": 663,
|
||||||
|
"flightSuffix": null,
|
||||||
|
"departure": {
|
||||||
|
"airportIata": "PHX",
|
||||||
|
"dateTime": "2026-04-28T10:58:00-07:00",
|
||||||
|
"terminal": "4"
|
||||||
|
},
|
||||||
|
"arrival": {
|
||||||
|
"airportIata": "KOA",
|
||||||
|
"dateTime": "2026-04-28T14:26:00-10:00",
|
||||||
|
"terminal": null
|
||||||
|
},
|
||||||
|
"durationMinutes": 388,
|
||||||
|
"equipmentIata": "32Q",
|
||||||
|
"serviceType": "J",
|
||||||
|
"isCodeshare": false,
|
||||||
|
"stops": 0,
|
||||||
|
"stopCodes": null,
|
||||||
|
"totalSeats": 196,
|
||||||
|
"classes": {
|
||||||
|
"first": {
|
||||||
|
"seats": 0,
|
||||||
|
"mealCodes": []
|
||||||
|
},
|
||||||
|
"business": {
|
||||||
|
"seats": 20,
|
||||||
|
"mealCodes": [
|
||||||
|
"L"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"premiumEconomy": {
|
||||||
|
"seats": 0,
|
||||||
|
"mealCodes": []
|
||||||
|
},
|
||||||
|
"economy": {
|
||||||
|
"seats": 176,
|
||||||
|
"mealCodes": [
|
||||||
|
"F",
|
||||||
|
"L"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"inFlightService": [
|
||||||
|
12,
|
||||||
|
18,
|
||||||
|
22,
|
||||||
|
23,
|
||||||
|
24,
|
||||||
|
26,
|
||||||
|
27,
|
||||||
|
28,
|
||||||
|
29,
|
||||||
|
31
|
||||||
|
],
|
||||||
|
"id": "AA_663_PHX_2026_04_28",
|
||||||
|
"carrierIata": "AA",
|
||||||
|
"carrierIcao": "AAL",
|
||||||
|
"isWetlease": false,
|
||||||
|
"codeshares": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"durationMinutes": 795,
|
||||||
|
"score": 1795,
|
||||||
|
"flights": [
|
||||||
|
{
|
||||||
|
"flightNumber": 644,
|
||||||
|
"flightSuffix": null,
|
||||||
|
"departure": {
|
||||||
|
"airportIata": "DFW",
|
||||||
|
"dateTime": "2026-04-28T07:15:00-05:00",
|
||||||
|
"terminal": "E"
|
||||||
|
},
|
||||||
|
"arrival": {
|
||||||
|
"airportIata": "DEN",
|
||||||
|
"dateTime": "2026-04-28T08:35:00-06:00",
|
||||||
|
"terminal": null
|
||||||
|
},
|
||||||
|
"durationMinutes": 140,
|
||||||
|
"equipmentIata": "319",
|
||||||
|
"serviceType": "J",
|
||||||
|
"isCodeshare": false,
|
||||||
|
"stops": 0,
|
||||||
|
"stopCodes": null,
|
||||||
|
"totalSeats": 126,
|
||||||
|
"classes": {
|
||||||
|
"first": {
|
||||||
|
"seats": 0,
|
||||||
|
"mealCodes": [
|
||||||
|
"S"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"business": {
|
||||||
|
"seats": 12,
|
||||||
|
"mealCodes": [
|
||||||
|
"S"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"premiumEconomy": {
|
||||||
|
"seats": 0,
|
||||||
|
"mealCodes": [
|
||||||
|
"G"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"economy": {
|
||||||
|
"seats": 114,
|
||||||
|
"mealCodes": [
|
||||||
|
"G"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"inFlightService": [],
|
||||||
|
"id": "UA_644_DFW_2026_04_28",
|
||||||
|
"carrierIata": "UA",
|
||||||
|
"carrierIcao": "UAL",
|
||||||
|
"isWetlease": false,
|
||||||
|
"codeshares": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"flightNumber": 1758,
|
||||||
|
"flightSuffix": null,
|
||||||
|
"departure": {
|
||||||
|
"airportIata": "DEN",
|
||||||
|
"dateTime": "2026-04-28T12:15:00-06:00",
|
||||||
|
"terminal": null
|
||||||
|
},
|
||||||
|
"arrival": {
|
||||||
|
"airportIata": "KOA",
|
||||||
|
"dateTime": "2026-04-28T15:30:00-10:00",
|
||||||
|
"terminal": null
|
||||||
|
},
|
||||||
|
"durationMinutes": 435,
|
||||||
|
"equipmentIata": "777",
|
||||||
|
"serviceType": "J",
|
||||||
|
"isCodeshare": false,
|
||||||
|
"stops": 0,
|
||||||
|
"stopCodes": null,
|
||||||
|
"totalSeats": 364,
|
||||||
|
"classes": {
|
||||||
|
"first": {
|
||||||
|
"seats": 0,
|
||||||
|
"mealCodes": [
|
||||||
|
"M"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"business": {
|
||||||
|
"seats": 28,
|
||||||
|
"mealCodes": [
|
||||||
|
"M"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"premiumEconomy": {
|
||||||
|
"seats": 0,
|
||||||
|
"mealCodes": [
|
||||||
|
"M"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"economy": {
|
||||||
|
"seats": 336,
|
||||||
|
"mealCodes": [
|
||||||
|
"M"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"inFlightService": [],
|
||||||
|
"id": "UA_1758_DEN_2026_04_28",
|
||||||
|
"carrierIata": "UA",
|
||||||
|
"carrierIcao": "UAL",
|
||||||
|
"isWetlease": false,
|
||||||
|
"codeshares": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"durationMinutes": 651,
|
||||||
|
"score": 1551,
|
||||||
|
"flights": [
|
||||||
|
{
|
||||||
|
"flightNumber": 520,
|
||||||
|
"flightSuffix": null,
|
||||||
|
"departure": {
|
||||||
|
"airportIata": "DFW",
|
||||||
|
"dateTime": "2026-04-28T08:35:00-05:00",
|
||||||
|
"terminal": "0"
|
||||||
|
},
|
||||||
|
"arrival": {
|
||||||
|
"airportIata": "PHX",
|
||||||
|
"dateTime": "2026-04-28T09:25:00-07:00",
|
||||||
|
"terminal": "4"
|
||||||
|
},
|
||||||
|
"durationMinutes": 170,
|
||||||
|
"equipmentIata": "738",
|
||||||
|
"serviceType": "J",
|
||||||
|
"isCodeshare": false,
|
||||||
|
"stops": 0,
|
||||||
|
"stopCodes": null,
|
||||||
|
"totalSeats": 172,
|
||||||
|
"classes": {
|
||||||
|
"first": {
|
||||||
|
"seats": 0,
|
||||||
|
"mealCodes": []
|
||||||
|
},
|
||||||
|
"business": {
|
||||||
|
"seats": 16,
|
||||||
|
"mealCodes": []
|
||||||
|
},
|
||||||
|
"premiumEconomy": {
|
||||||
|
"seats": 0,
|
||||||
|
"mealCodes": []
|
||||||
|
},
|
||||||
|
"economy": {
|
||||||
|
"seats": 156,
|
||||||
|
"mealCodes": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"inFlightService": [
|
||||||
|
12,
|
||||||
|
18,
|
||||||
|
22,
|
||||||
|
23,
|
||||||
|
26
|
||||||
|
],
|
||||||
|
"id": "AA_520_DFW_2026_04_28",
|
||||||
|
"carrierIata": "AA",
|
||||||
|
"carrierIcao": "AAL",
|
||||||
|
"isWetlease": false,
|
||||||
|
"codeshares": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"flightNumber": 663,
|
||||||
|
"flightSuffix": null,
|
||||||
|
"departure": {
|
||||||
|
"airportIata": "PHX",
|
||||||
|
"dateTime": "2026-04-28T10:58:00-07:00",
|
||||||
|
"terminal": "4"
|
||||||
|
},
|
||||||
|
"arrival": {
|
||||||
|
"airportIata": "KOA",
|
||||||
|
"dateTime": "2026-04-28T14:26:00-10:00",
|
||||||
|
"terminal": null
|
||||||
|
},
|
||||||
|
"durationMinutes": 388,
|
||||||
|
"equipmentIata": "32Q",
|
||||||
|
"serviceType": "J",
|
||||||
|
"isCodeshare": false,
|
||||||
|
"stops": 0,
|
||||||
|
"stopCodes": null,
|
||||||
|
"totalSeats": 196,
|
||||||
|
"classes": {
|
||||||
|
"first": {
|
||||||
|
"seats": 0,
|
||||||
|
"mealCodes": []
|
||||||
|
},
|
||||||
|
"business": {
|
||||||
|
"seats": 20,
|
||||||
|
"mealCodes": [
|
||||||
|
"L"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"premiumEconomy": {
|
||||||
|
"seats": 0,
|
||||||
|
"mealCodes": []
|
||||||
|
},
|
||||||
|
"economy": {
|
||||||
|
"seats": 176,
|
||||||
|
"mealCodes": [
|
||||||
|
"F",
|
||||||
|
"L"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"inFlightService": [
|
||||||
|
12,
|
||||||
|
18,
|
||||||
|
22,
|
||||||
|
23,
|
||||||
|
24,
|
||||||
|
26,
|
||||||
|
27,
|
||||||
|
28,
|
||||||
|
29,
|
||||||
|
31
|
||||||
|
],
|
||||||
|
"id": "AA_663_PHX_2026_04_28",
|
||||||
|
"carrierIata": "AA",
|
||||||
|
"carrierIcao": "AAL",
|
||||||
|
"isWetlease": false,
|
||||||
|
"codeshares": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"durationMinutes": 952,
|
||||||
|
"score": 2252,
|
||||||
|
"flights": [
|
||||||
|
{
|
||||||
|
"flightNumber": 365,
|
||||||
|
"flightSuffix": null,
|
||||||
|
"departure": {
|
||||||
|
"airportIata": "DFW",
|
||||||
|
"dateTime": "2026-04-28T09:00:00-05:00",
|
||||||
|
"terminal": "E"
|
||||||
|
},
|
||||||
|
"arrival": {
|
||||||
|
"airportIata": "SEA",
|
||||||
|
"dateTime": "2026-04-28T11:27:00-07:00",
|
||||||
|
"terminal": null
|
||||||
|
},
|
||||||
|
"durationMinutes": 267,
|
||||||
|
"equipmentIata": "73J",
|
||||||
|
"serviceType": "J",
|
||||||
|
"isCodeshare": false,
|
||||||
|
"stops": 0,
|
||||||
|
"stopCodes": null,
|
||||||
|
"totalSeats": 178,
|
||||||
|
"classes": {
|
||||||
|
"first": {
|
||||||
|
"seats": 0,
|
||||||
|
"mealCodes": []
|
||||||
|
},
|
||||||
|
"business": {
|
||||||
|
"seats": 16,
|
||||||
|
"mealCodes": [
|
||||||
|
"B"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"premiumEconomy": {
|
||||||
|
"seats": 0,
|
||||||
|
"mealCodes": []
|
||||||
|
},
|
||||||
|
"economy": {
|
||||||
|
"seats": 162,
|
||||||
|
"mealCodes": [
|
||||||
|
"B"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"inFlightService": [
|
||||||
|
3,
|
||||||
|
12,
|
||||||
|
18
|
||||||
|
],
|
||||||
|
"id": "AS_365_DFW_2026_04_28",
|
||||||
|
"carrierIata": "AS",
|
||||||
|
"carrierIcao": "ASA",
|
||||||
|
"isWetlease": false,
|
||||||
|
"codeshares": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"flightNumber": 238,
|
||||||
|
"flightSuffix": null,
|
||||||
|
"departure": {
|
||||||
|
"airportIata": "SEA",
|
||||||
|
"dateTime": "2026-04-28T16:27:00-07:00",
|
||||||
|
"terminal": null
|
||||||
|
},
|
||||||
|
"arrival": {
|
||||||
|
"airportIata": "KOA",
|
||||||
|
"dateTime": "2026-04-28T19:52:00-10:00",
|
||||||
|
"terminal": null
|
||||||
|
},
|
||||||
|
"durationMinutes": 385,
|
||||||
|
"equipmentIata": "7M9",
|
||||||
|
"serviceType": "J",
|
||||||
|
"isCodeshare": false,
|
||||||
|
"stops": 0,
|
||||||
|
"stopCodes": null,
|
||||||
|
"totalSeats": 178,
|
||||||
|
"classes": {
|
||||||
|
"first": {
|
||||||
|
"seats": 0,
|
||||||
|
"mealCodes": []
|
||||||
|
},
|
||||||
|
"business": {
|
||||||
|
"seats": 16,
|
||||||
|
"mealCodes": [
|
||||||
|
"D"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"premiumEconomy": {
|
||||||
|
"seats": 0,
|
||||||
|
"mealCodes": []
|
||||||
|
},
|
||||||
|
"economy": {
|
||||||
|
"seats": 162,
|
||||||
|
"mealCodes": [
|
||||||
|
"D"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"inFlightService": [
|
||||||
|
3,
|
||||||
|
12,
|
||||||
|
18
|
||||||
|
],
|
||||||
|
"id": "AS_238_SEA_2026_04_28",
|
||||||
|
"carrierIata": "AS",
|
||||||
|
"carrierIcao": "ASA",
|
||||||
|
"isWetlease": false,
|
||||||
|
"codeshares": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"appendix": {
|
||||||
|
"airports": [
|
||||||
|
{
|
||||||
|
"iataCode": "DFW",
|
||||||
|
"name": "Dallas Dallas/Fort Worth Intl Apt",
|
||||||
|
"cityCode": "DFW",
|
||||||
|
"countryCode": "US",
|
||||||
|
"stateCode": "TX",
|
||||||
|
"latitude": 32.896944,
|
||||||
|
"longitude": -97.038056,
|
||||||
|
"timezone": "America/Chicago",
|
||||||
|
"locationType": "A",
|
||||||
|
"locationCategory": "A",
|
||||||
|
"icaoCode": "KDFW",
|
||||||
|
"cityName": "Dallas",
|
||||||
|
"countryName": "United States"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"iataCode": "SEA",
|
||||||
|
"name": "Seattle-Tacoma International Apt",
|
||||||
|
"cityCode": "SEA",
|
||||||
|
"countryCode": "US",
|
||||||
|
"stateCode": "WA",
|
||||||
|
"latitude": 47.448889,
|
||||||
|
"longitude": -122.309444,
|
||||||
|
"timezone": "America/Los_Angeles",
|
||||||
|
"locationType": "A",
|
||||||
|
"locationCategory": "A",
|
||||||
|
"icaoCode": "KSEA",
|
||||||
|
"cityName": "Seattle",
|
||||||
|
"countryName": "United States"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"iataCode": "KOA",
|
||||||
|
"name": "Kona",
|
||||||
|
"cityCode": "KOA",
|
||||||
|
"countryCode": "US",
|
||||||
|
"stateCode": "HI",
|
||||||
|
"latitude": 19.738889,
|
||||||
|
"longitude": -156.045556,
|
||||||
|
"timezone": "Pacific/Honolulu",
|
||||||
|
"locationType": "L",
|
||||||
|
"locationCategory": "A",
|
||||||
|
"icaoCode": "PHKO",
|
||||||
|
"cityName": "Kona",
|
||||||
|
"countryName": "United States"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"iataCode": "PHX",
|
||||||
|
"name": "Phoenix Sky Harbor Intl Apt",
|
||||||
|
"cityCode": "PHX",
|
||||||
|
"countryCode": "US",
|
||||||
|
"stateCode": "AZ",
|
||||||
|
"latitude": 33.434167,
|
||||||
|
"longitude": -112.011667,
|
||||||
|
"timezone": "America/Phoenix",
|
||||||
|
"locationType": "A",
|
||||||
|
"locationCategory": "A",
|
||||||
|
"icaoCode": "KPHX",
|
||||||
|
"cityName": "Phoenix",
|
||||||
|
"countryName": "United States"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"iataCode": "DEN",
|
||||||
|
"name": "Denver Intl Apt",
|
||||||
|
"cityCode": "DEN",
|
||||||
|
"countryCode": "US",
|
||||||
|
"stateCode": "CO",
|
||||||
|
"latitude": 39.861667,
|
||||||
|
"longitude": -104.673056,
|
||||||
|
"timezone": "America/Denver",
|
||||||
|
"locationType": "A",
|
||||||
|
"locationCategory": "A",
|
||||||
|
"icaoCode": "KDEN",
|
||||||
|
"cityName": "Denver",
|
||||||
|
"countryName": "United States"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"airlines": [
|
||||||
|
{
|
||||||
|
"iataCode": "AS",
|
||||||
|
"icaoCode": "ASA",
|
||||||
|
"name": "Alaska Airlines",
|
||||||
|
"countryCode": "US",
|
||||||
|
"alliance": "oneworld",
|
||||||
|
"isLowCost": false,
|
||||||
|
"isControlledDuplicate": false,
|
||||||
|
"websiteUrl": "https://www.alaskaair.com",
|
||||||
|
"webCheckinUrl": "https://webselfservice.alaskaair.com/checkinweb/default.aspx?language=",
|
||||||
|
"mobileCheckinUrl": "https://m.alaskaair.com/checkin",
|
||||||
|
"baggageUrl": "https://www.alaskaair.com/content/travel-info/baggage/overview",
|
||||||
|
"foundedYear": 1932,
|
||||||
|
"callsign": "ALASKA"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"iataCode": "AA",
|
||||||
|
"icaoCode": "AAL",
|
||||||
|
"name": "American Airlines",
|
||||||
|
"countryCode": "US",
|
||||||
|
"alliance": "oneworld",
|
||||||
|
"isLowCost": false,
|
||||||
|
"isControlledDuplicate": false,
|
||||||
|
"websiteUrl": "https://www.aa.com",
|
||||||
|
"webCheckinUrl": "https://www.aa.com/reservation/flightCheckInViewReservationsAccess.do",
|
||||||
|
"mobileCheckinUrl": null,
|
||||||
|
"baggageUrl": "https://www.aa.com/i18n/travel-info/baggage/baggage.jsp?anchorEvent=false&from=Nav",
|
||||||
|
"foundedYear": 1926,
|
||||||
|
"callsign": "AMERICAN"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"iataCode": "UA",
|
||||||
|
"icaoCode": "UAL",
|
||||||
|
"name": "United Airlines",
|
||||||
|
"countryCode": "US",
|
||||||
|
"alliance": "star-alliance",
|
||||||
|
"isLowCost": false,
|
||||||
|
"isControlledDuplicate": false,
|
||||||
|
"websiteUrl": "https://www.united.com/en/us/",
|
||||||
|
"webCheckinUrl": "https://www.united.com/travel/checkin/start.aspx",
|
||||||
|
"mobileCheckinUrl": null,
|
||||||
|
"baggageUrl": "https://www.united.com/en/us/fly/baggage.html",
|
||||||
|
"foundedYear": 1926,
|
||||||
|
"callsign": "UNITED"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"equipment": [
|
||||||
|
{
|
||||||
|
"iataCode": "73J",
|
||||||
|
"icaoCode": null,
|
||||||
|
"generalCode": "737",
|
||||||
|
"name": "Boeing 737-900 (Winglets) Passenger",
|
||||||
|
"equipmentType": "J",
|
||||||
|
"bodyType": "N",
|
||||||
|
"isSurfaceTransport": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"iataCode": "7M9",
|
||||||
|
"icaoCode": null,
|
||||||
|
"generalCode": "737",
|
||||||
|
"name": "Boeing 737 MAX 9 Passenger",
|
||||||
|
"equipmentType": "J",
|
||||||
|
"bodyType": "N",
|
||||||
|
"isSurfaceTransport": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"iataCode": "321",
|
||||||
|
"icaoCode": null,
|
||||||
|
"generalCode": "32S",
|
||||||
|
"name": "Airbus A321 Passenger",
|
||||||
|
"equipmentType": "J",
|
||||||
|
"bodyType": "N",
|
||||||
|
"isSurfaceTransport": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"iataCode": "32Q",
|
||||||
|
"icaoCode": null,
|
||||||
|
"generalCode": "32S",
|
||||||
|
"name": "Airbus A321neo/LR/XLR Passenger",
|
||||||
|
"equipmentType": "J",
|
||||||
|
"bodyType": "N",
|
||||||
|
"isSurfaceTransport": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"iataCode": "319",
|
||||||
|
"icaoCode": null,
|
||||||
|
"generalCode": "32S",
|
||||||
|
"name": "Airbus A319 Passenger",
|
||||||
|
"equipmentType": "J",
|
||||||
|
"bodyType": "N",
|
||||||
|
"isSurfaceTransport": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"iataCode": "777",
|
||||||
|
"icaoCode": null,
|
||||||
|
"generalCode": "777",
|
||||||
|
"name": "Boeing 777 Passenger",
|
||||||
|
"equipmentType": "J",
|
||||||
|
"bodyType": "W",
|
||||||
|
"isSurfaceTransport": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"iataCode": "738",
|
||||||
|
"icaoCode": null,
|
||||||
|
"generalCode": "737",
|
||||||
|
"name": "Boeing 737-800 Passenger",
|
||||||
|
"equipmentType": "J",
|
||||||
|
"bodyType": "N",
|
||||||
|
"isSurfaceTransport": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,556 @@
|
|||||||
|
{
|
||||||
|
"json": {
|
||||||
|
"flights": [
|
||||||
|
{
|
||||||
|
"flightNumber": 2178,
|
||||||
|
"flightSuffix": null,
|
||||||
|
"departure": {
|
||||||
|
"airportIata": "DFW",
|
||||||
|
"dateTime": "2026-04-24T16:45:00-05:00",
|
||||||
|
"terminal": "0"
|
||||||
|
},
|
||||||
|
"arrival": {
|
||||||
|
"airportIata": "LAS",
|
||||||
|
"dateTime": "2026-04-24T17:46:00-07:00",
|
||||||
|
"terminal": "1"
|
||||||
|
},
|
||||||
|
"durationMinutes": 181,
|
||||||
|
"equipmentIata": "32Q",
|
||||||
|
"serviceType": "J",
|
||||||
|
"isCodeshare": false,
|
||||||
|
"stops": 0,
|
||||||
|
"stopCodes": null,
|
||||||
|
"totalSeats": 196,
|
||||||
|
"classes": {
|
||||||
|
"first": {
|
||||||
|
"seats": 0,
|
||||||
|
"mealCodes": []
|
||||||
|
},
|
||||||
|
"business": {
|
||||||
|
"seats": 20,
|
||||||
|
"mealCodes": [
|
||||||
|
"D"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"premiumEconomy": {
|
||||||
|
"seats": 0,
|
||||||
|
"mealCodes": []
|
||||||
|
},
|
||||||
|
"economy": {
|
||||||
|
"seats": 176,
|
||||||
|
"mealCodes": [
|
||||||
|
"R",
|
||||||
|
"D"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"inFlightService": [
|
||||||
|
12,
|
||||||
|
18,
|
||||||
|
22,
|
||||||
|
23,
|
||||||
|
24,
|
||||||
|
26,
|
||||||
|
27,
|
||||||
|
28,
|
||||||
|
29,
|
||||||
|
31
|
||||||
|
],
|
||||||
|
"id": "AA_2178_DFW_2026_04_24",
|
||||||
|
"carrierIata": "AA",
|
||||||
|
"carrierIcao": "AAL",
|
||||||
|
"isWetlease": false,
|
||||||
|
"codeshares": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"flightNumber": 2178,
|
||||||
|
"flightSuffix": null,
|
||||||
|
"departure": {
|
||||||
|
"airportIata": "DFW",
|
||||||
|
"dateTime": "2026-04-25T16:45:00-05:00",
|
||||||
|
"terminal": "0"
|
||||||
|
},
|
||||||
|
"arrival": {
|
||||||
|
"airportIata": "LAS",
|
||||||
|
"dateTime": "2026-04-25T17:46:00-07:00",
|
||||||
|
"terminal": "1"
|
||||||
|
},
|
||||||
|
"durationMinutes": 181,
|
||||||
|
"equipmentIata": "32Q",
|
||||||
|
"serviceType": "J",
|
||||||
|
"isCodeshare": false,
|
||||||
|
"stops": 0,
|
||||||
|
"stopCodes": null,
|
||||||
|
"totalSeats": 196,
|
||||||
|
"classes": {
|
||||||
|
"first": {
|
||||||
|
"seats": 0,
|
||||||
|
"mealCodes": []
|
||||||
|
},
|
||||||
|
"business": {
|
||||||
|
"seats": 20,
|
||||||
|
"mealCodes": [
|
||||||
|
"D"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"premiumEconomy": {
|
||||||
|
"seats": 0,
|
||||||
|
"mealCodes": []
|
||||||
|
},
|
||||||
|
"economy": {
|
||||||
|
"seats": 176,
|
||||||
|
"mealCodes": [
|
||||||
|
"R",
|
||||||
|
"D"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"inFlightService": [
|
||||||
|
12,
|
||||||
|
18,
|
||||||
|
22,
|
||||||
|
23,
|
||||||
|
24,
|
||||||
|
26,
|
||||||
|
27,
|
||||||
|
28,
|
||||||
|
29,
|
||||||
|
31
|
||||||
|
],
|
||||||
|
"id": "AA_2178_DFW_2026_04_25",
|
||||||
|
"carrierIata": "AA",
|
||||||
|
"carrierIcao": "AAL",
|
||||||
|
"isWetlease": false,
|
||||||
|
"codeshares": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"flightNumber": 2178,
|
||||||
|
"flightSuffix": null,
|
||||||
|
"departure": {
|
||||||
|
"airportIata": "DFW",
|
||||||
|
"dateTime": "2026-04-26T16:45:00-05:00",
|
||||||
|
"terminal": "0"
|
||||||
|
},
|
||||||
|
"arrival": {
|
||||||
|
"airportIata": "LAS",
|
||||||
|
"dateTime": "2026-04-26T17:46:00-07:00",
|
||||||
|
"terminal": "1"
|
||||||
|
},
|
||||||
|
"durationMinutes": 181,
|
||||||
|
"equipmentIata": "32Q",
|
||||||
|
"serviceType": "J",
|
||||||
|
"isCodeshare": false,
|
||||||
|
"stops": 0,
|
||||||
|
"stopCodes": null,
|
||||||
|
"totalSeats": 196,
|
||||||
|
"classes": {
|
||||||
|
"first": {
|
||||||
|
"seats": 0,
|
||||||
|
"mealCodes": []
|
||||||
|
},
|
||||||
|
"business": {
|
||||||
|
"seats": 20,
|
||||||
|
"mealCodes": [
|
||||||
|
"D"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"premiumEconomy": {
|
||||||
|
"seats": 0,
|
||||||
|
"mealCodes": []
|
||||||
|
},
|
||||||
|
"economy": {
|
||||||
|
"seats": 176,
|
||||||
|
"mealCodes": [
|
||||||
|
"R",
|
||||||
|
"D"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"inFlightService": [
|
||||||
|
12,
|
||||||
|
18,
|
||||||
|
22,
|
||||||
|
23,
|
||||||
|
24,
|
||||||
|
26,
|
||||||
|
27,
|
||||||
|
28,
|
||||||
|
29,
|
||||||
|
31
|
||||||
|
],
|
||||||
|
"id": "AA_2178_DFW_2026_04_26",
|
||||||
|
"carrierIata": "AA",
|
||||||
|
"carrierIcao": "AAL",
|
||||||
|
"isWetlease": false,
|
||||||
|
"codeshares": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"flightNumber": 2178,
|
||||||
|
"flightSuffix": null,
|
||||||
|
"departure": {
|
||||||
|
"airportIata": "DFW",
|
||||||
|
"dateTime": "2026-04-27T16:45:00-05:00",
|
||||||
|
"terminal": "0"
|
||||||
|
},
|
||||||
|
"arrival": {
|
||||||
|
"airportIata": "LAS",
|
||||||
|
"dateTime": "2026-04-27T17:46:00-07:00",
|
||||||
|
"terminal": "1"
|
||||||
|
},
|
||||||
|
"durationMinutes": 181,
|
||||||
|
"equipmentIata": "32Q",
|
||||||
|
"serviceType": "J",
|
||||||
|
"isCodeshare": false,
|
||||||
|
"stops": 0,
|
||||||
|
"stopCodes": null,
|
||||||
|
"totalSeats": 196,
|
||||||
|
"classes": {
|
||||||
|
"first": {
|
||||||
|
"seats": 0,
|
||||||
|
"mealCodes": []
|
||||||
|
},
|
||||||
|
"business": {
|
||||||
|
"seats": 20,
|
||||||
|
"mealCodes": [
|
||||||
|
"D"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"premiumEconomy": {
|
||||||
|
"seats": 0,
|
||||||
|
"mealCodes": []
|
||||||
|
},
|
||||||
|
"economy": {
|
||||||
|
"seats": 176,
|
||||||
|
"mealCodes": [
|
||||||
|
"R",
|
||||||
|
"D"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"inFlightService": [
|
||||||
|
12,
|
||||||
|
18,
|
||||||
|
22,
|
||||||
|
23,
|
||||||
|
24,
|
||||||
|
26,
|
||||||
|
27,
|
||||||
|
28,
|
||||||
|
29,
|
||||||
|
31
|
||||||
|
],
|
||||||
|
"id": "AA_2178_DFW_2026_04_27",
|
||||||
|
"carrierIata": "AA",
|
||||||
|
"carrierIcao": "AAL",
|
||||||
|
"isWetlease": false,
|
||||||
|
"codeshares": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"flightNumber": 2178,
|
||||||
|
"flightSuffix": null,
|
||||||
|
"departure": {
|
||||||
|
"airportIata": "DFW",
|
||||||
|
"dateTime": "2026-04-28T16:45:00-05:00",
|
||||||
|
"terminal": "0"
|
||||||
|
},
|
||||||
|
"arrival": {
|
||||||
|
"airportIata": "LAS",
|
||||||
|
"dateTime": "2026-04-28T17:46:00-07:00",
|
||||||
|
"terminal": "1"
|
||||||
|
},
|
||||||
|
"durationMinutes": 181,
|
||||||
|
"equipmentIata": "32Q",
|
||||||
|
"serviceType": "J",
|
||||||
|
"isCodeshare": false,
|
||||||
|
"stops": 0,
|
||||||
|
"stopCodes": null,
|
||||||
|
"totalSeats": 196,
|
||||||
|
"classes": {
|
||||||
|
"first": {
|
||||||
|
"seats": 0,
|
||||||
|
"mealCodes": []
|
||||||
|
},
|
||||||
|
"business": {
|
||||||
|
"seats": 20,
|
||||||
|
"mealCodes": [
|
||||||
|
"D"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"premiumEconomy": {
|
||||||
|
"seats": 0,
|
||||||
|
"mealCodes": []
|
||||||
|
},
|
||||||
|
"economy": {
|
||||||
|
"seats": 176,
|
||||||
|
"mealCodes": [
|
||||||
|
"R",
|
||||||
|
"D"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"inFlightService": [
|
||||||
|
12,
|
||||||
|
18,
|
||||||
|
22,
|
||||||
|
23,
|
||||||
|
24,
|
||||||
|
26,
|
||||||
|
27,
|
||||||
|
28,
|
||||||
|
29,
|
||||||
|
31
|
||||||
|
],
|
||||||
|
"id": "AA_2178_DFW_2026_04_28",
|
||||||
|
"carrierIata": "AA",
|
||||||
|
"carrierIcao": "AAL",
|
||||||
|
"isWetlease": false,
|
||||||
|
"codeshares": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"flightNumber": 2178,
|
||||||
|
"flightSuffix": null,
|
||||||
|
"departure": {
|
||||||
|
"airportIata": "DFW",
|
||||||
|
"dateTime": "2026-04-29T16:45:00-05:00",
|
||||||
|
"terminal": "0"
|
||||||
|
},
|
||||||
|
"arrival": {
|
||||||
|
"airportIata": "LAS",
|
||||||
|
"dateTime": "2026-04-29T17:46:00-07:00",
|
||||||
|
"terminal": "1"
|
||||||
|
},
|
||||||
|
"durationMinutes": 181,
|
||||||
|
"equipmentIata": "32Q",
|
||||||
|
"serviceType": "J",
|
||||||
|
"isCodeshare": false,
|
||||||
|
"stops": 0,
|
||||||
|
"stopCodes": null,
|
||||||
|
"totalSeats": 196,
|
||||||
|
"classes": {
|
||||||
|
"first": {
|
||||||
|
"seats": 0,
|
||||||
|
"mealCodes": []
|
||||||
|
},
|
||||||
|
"business": {
|
||||||
|
"seats": 20,
|
||||||
|
"mealCodes": [
|
||||||
|
"D"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"premiumEconomy": {
|
||||||
|
"seats": 0,
|
||||||
|
"mealCodes": []
|
||||||
|
},
|
||||||
|
"economy": {
|
||||||
|
"seats": 176,
|
||||||
|
"mealCodes": [
|
||||||
|
"R",
|
||||||
|
"D"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"inFlightService": [
|
||||||
|
12,
|
||||||
|
18,
|
||||||
|
22,
|
||||||
|
23,
|
||||||
|
24,
|
||||||
|
26,
|
||||||
|
27,
|
||||||
|
28,
|
||||||
|
29,
|
||||||
|
31
|
||||||
|
],
|
||||||
|
"id": "AA_2178_DFW_2026_04_29",
|
||||||
|
"carrierIata": "AA",
|
||||||
|
"carrierIcao": "AAL",
|
||||||
|
"isWetlease": false,
|
||||||
|
"codeshares": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"flightNumber": 2178,
|
||||||
|
"flightSuffix": null,
|
||||||
|
"departure": {
|
||||||
|
"airportIata": "DFW",
|
||||||
|
"dateTime": "2026-04-30T16:45:00-05:00",
|
||||||
|
"terminal": "0"
|
||||||
|
},
|
||||||
|
"arrival": {
|
||||||
|
"airportIata": "LAS",
|
||||||
|
"dateTime": "2026-04-30T17:46:00-07:00",
|
||||||
|
"terminal": "1"
|
||||||
|
},
|
||||||
|
"durationMinutes": 181,
|
||||||
|
"equipmentIata": "32Q",
|
||||||
|
"serviceType": "J",
|
||||||
|
"isCodeshare": false,
|
||||||
|
"stops": 0,
|
||||||
|
"stopCodes": null,
|
||||||
|
"totalSeats": 196,
|
||||||
|
"classes": {
|
||||||
|
"first": {
|
||||||
|
"seats": 0,
|
||||||
|
"mealCodes": []
|
||||||
|
},
|
||||||
|
"business": {
|
||||||
|
"seats": 20,
|
||||||
|
"mealCodes": [
|
||||||
|
"D"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"premiumEconomy": {
|
||||||
|
"seats": 0,
|
||||||
|
"mealCodes": []
|
||||||
|
},
|
||||||
|
"economy": {
|
||||||
|
"seats": 176,
|
||||||
|
"mealCodes": [
|
||||||
|
"R",
|
||||||
|
"D"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"inFlightService": [
|
||||||
|
12,
|
||||||
|
18,
|
||||||
|
22,
|
||||||
|
23,
|
||||||
|
24,
|
||||||
|
26,
|
||||||
|
27,
|
||||||
|
28,
|
||||||
|
29,
|
||||||
|
31
|
||||||
|
],
|
||||||
|
"id": "AA_2178_DFW_2026_04_30",
|
||||||
|
"carrierIata": "AA",
|
||||||
|
"carrierIcao": "AAL",
|
||||||
|
"isWetlease": false,
|
||||||
|
"codeshares": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"flightNumber": 2178,
|
||||||
|
"flightSuffix": null,
|
||||||
|
"departure": {
|
||||||
|
"airportIata": "DFW",
|
||||||
|
"dateTime": "2026-05-01T16:45:00-05:00",
|
||||||
|
"terminal": "0"
|
||||||
|
},
|
||||||
|
"arrival": {
|
||||||
|
"airportIata": "LAS",
|
||||||
|
"dateTime": "2026-05-01T17:46:00-07:00",
|
||||||
|
"terminal": "1"
|
||||||
|
},
|
||||||
|
"durationMinutes": 181,
|
||||||
|
"equipmentIata": "32Q",
|
||||||
|
"serviceType": "J",
|
||||||
|
"isCodeshare": false,
|
||||||
|
"stops": 0,
|
||||||
|
"stopCodes": null,
|
||||||
|
"totalSeats": 196,
|
||||||
|
"classes": {
|
||||||
|
"first": {
|
||||||
|
"seats": 0,
|
||||||
|
"mealCodes": []
|
||||||
|
},
|
||||||
|
"business": {
|
||||||
|
"seats": 20,
|
||||||
|
"mealCodes": [
|
||||||
|
"D"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"premiumEconomy": {
|
||||||
|
"seats": 0,
|
||||||
|
"mealCodes": []
|
||||||
|
},
|
||||||
|
"economy": {
|
||||||
|
"seats": 176,
|
||||||
|
"mealCodes": [
|
||||||
|
"R",
|
||||||
|
"D"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"inFlightService": [
|
||||||
|
12,
|
||||||
|
18,
|
||||||
|
22,
|
||||||
|
23,
|
||||||
|
24,
|
||||||
|
26,
|
||||||
|
27,
|
||||||
|
28,
|
||||||
|
29,
|
||||||
|
31
|
||||||
|
],
|
||||||
|
"id": "AA_2178_DFW_2026_05_01",
|
||||||
|
"carrierIata": "AA",
|
||||||
|
"carrierIcao": "AAL",
|
||||||
|
"isWetlease": false,
|
||||||
|
"codeshares": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"appendix": {
|
||||||
|
"airports": [
|
||||||
|
{
|
||||||
|
"iataCode": "DFW",
|
||||||
|
"name": "Dallas Dallas/Fort Worth Intl Apt",
|
||||||
|
"cityCode": "DFW",
|
||||||
|
"countryCode": "US",
|
||||||
|
"stateCode": "TX",
|
||||||
|
"latitude": 32.896944,
|
||||||
|
"longitude": -97.038056,
|
||||||
|
"timezone": "America/Chicago",
|
||||||
|
"locationType": "A",
|
||||||
|
"locationCategory": "A",
|
||||||
|
"icaoCode": "KDFW",
|
||||||
|
"cityName": "Dallas",
|
||||||
|
"countryName": "United States"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"iataCode": "LAS",
|
||||||
|
"name": "Las Vegas Harry Reid Intl",
|
||||||
|
"cityCode": "LAS",
|
||||||
|
"countryCode": "US",
|
||||||
|
"stateCode": "NV",
|
||||||
|
"latitude": 36.08,
|
||||||
|
"longitude": -115.152222,
|
||||||
|
"timezone": "America/Los_Angeles",
|
||||||
|
"locationType": "A",
|
||||||
|
"locationCategory": "A",
|
||||||
|
"icaoCode": "KLAS",
|
||||||
|
"cityName": "Las Vegas",
|
||||||
|
"countryName": "United States"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"airlines": [
|
||||||
|
{
|
||||||
|
"iataCode": "AA",
|
||||||
|
"icaoCode": "AAL",
|
||||||
|
"name": "American Airlines",
|
||||||
|
"countryCode": "US",
|
||||||
|
"alliance": "oneworld",
|
||||||
|
"isLowCost": false,
|
||||||
|
"isControlledDuplicate": false,
|
||||||
|
"websiteUrl": "https://www.aa.com",
|
||||||
|
"webCheckinUrl": "https://www.aa.com/reservation/flightCheckInViewReservationsAccess.do",
|
||||||
|
"mobileCheckinUrl": null,
|
||||||
|
"baggageUrl": "https://www.aa.com/i18n/travel-info/baggage/baggage.jsp?anchorEvent=false&from=Nav",
|
||||||
|
"foundedYear": 1926,
|
||||||
|
"callsign": "AMERICAN"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"equipment": [
|
||||||
|
{
|
||||||
|
"iataCode": "32Q",
|
||||||
|
"icaoCode": null,
|
||||||
|
"generalCode": "32S",
|
||||||
|
"name": "Airbus A321neo/LR/XLR Passenger",
|
||||||
|
"equipmentType": "J",
|
||||||
|
"bodyType": "N",
|
||||||
|
"isSurfaceTransport": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user