62729213d7
AA was silently returning nil because the server now rejects User-Agent
"Android/2025.31" with HTTP 403 ("Please update your version of the
American Airlines app"). Bumped to "2026.14" (matches the APK in
airlines/) and centralized to a constant so the next bump is one line.
Added comprehensive logging to fetchAmericanLoad (was zero) so the next
breakage won't be silent — including an explicit ⚠️ when the server
returns the "update your version" payload.
New FlightsTests target with AirlineLoadIntegrationTests — hits live
airline APIs to verify each fetcher still returns data. Per-airline
strategy:
- Try route-explorer /departures from carrier hubs for a flight in the
next 24h (works for AA/UA/AS/B6).
- Fall back to a known-good daily flight when route-explorer doesn't
have the carrier in its data (NK/EK/KE — ULCC + some intl carriers).
- B6/EK/NK are status-only by design (no standby data without a PNR);
asserted as non-nil only.
- XE (JSX) skipped: needs WKWebView host.
Retries on route-explorer 429 by parsing the `retryAfter` field and
sleeping the indicated number of seconds. Static-shared client+services
across tests so the token cache survives.
Results 2026-05-26 (xcodebuild test -scheme Flights):
✅ AA, AS, B6, EK, KE, UA ❌ NK ⏭️ XE
NK (Spirit) is now broken: GetFlightInfoBI returns HTTP 403 with
{"getFlightInfoBIResult":null}. APIM key still accepted (401 without
it), but the call itself is rejected. Documented in
AIRLINE_INTEGRATION_GUIDE.md as a known regression to fix; likely
needs reverse-engineering against the current Spirit APK in airlines/.
Also: enable shared schemes in .gitignore so `xcodebuild test` works
out of the box for anyone cloning the repo.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1137 lines
48 KiB
Swift
1137 lines
48 KiB
Swift
import Foundation
|
|
|
|
/// Queries airline APIs for flight load and standby data.
|
|
/// Each airline has its own endpoint and response format.
|
|
actor AirlineLoadService {
|
|
|
|
// MARK: - Properties
|
|
|
|
private let session: URLSession
|
|
private let airportDatabase: AirportDatabase?
|
|
private var unitedToken: (hash: String, expiresAt: Date)?
|
|
|
|
// MARK: - Init
|
|
|
|
init(airportDatabase: AirportDatabase? = nil) {
|
|
let config = URLSessionConfiguration.default
|
|
config.timeoutIntervalForRequest = 15
|
|
session = URLSession(configuration: config)
|
|
self.airportDatabase = airportDatabase
|
|
}
|
|
|
|
// MARK: - Public Router
|
|
|
|
/// Route to the correct airline based on IATA code.
|
|
///
|
|
/// `departureTime` is an optional "HH:mm" disambiguator for airlines
|
|
/// (currently JSX / XE) where the flightconnections scraper can return
|
|
/// multiple rows sharing the same flight number but with different
|
|
/// departure times. When provided, the airline-specific fetcher can
|
|
/// match by departure time as a secondary signal.
|
|
func fetchLoad(
|
|
airlineCode: String,
|
|
flightNumber: String,
|
|
date: Date,
|
|
origin: String,
|
|
destination: String,
|
|
departureTime: String? = nil
|
|
) async -> FlightLoad? {
|
|
let code = airlineCode.uppercased()
|
|
print("[LoadService] Fetching load for \(code) flight \(flightNumber) \(origin)->\(destination)"
|
|
+ (departureTime.map { " @ \($0)" } ?? ""))
|
|
switch code {
|
|
case "UA": return await fetchUnitedLoad(flightNumber: flightNumber, date: date, origin: origin)
|
|
case "AA": return await fetchAmericanLoad(flightNumber: flightNumber, date: date, origin: origin, destination: destination)
|
|
case "NK": return await fetchSpiritStatus(origin: origin, destination: destination, date: date)
|
|
case "KE": return await fetchKoreanAirLoad(flightNumber: flightNumber, date: date, origin: origin, destination: destination)
|
|
case "B6": return await fetchJetBlueStatus(flightNumber: flightNumber, date: date, origin: origin)
|
|
case "AS": return await fetchAlaskaLoad(flightNumber: flightNumber, date: date, origin: origin, destination: destination)
|
|
case "EK": return await fetchEmiratesStatus(flightNumber: flightNumber, date: date, origin: origin)
|
|
case "XE": return await fetchJSXLoad(flightNumber: flightNumber, date: date, origin: origin, destination: destination, departureTime: departureTime)
|
|
default:
|
|
print("[LoadService] Unsupported airline: \(code)")
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// MARK: - United Airlines
|
|
|
|
/// Fetch an anonymous auth token from United, caching until expiry.
|
|
private func getUnitedToken() async -> String? {
|
|
if let cached = unitedToken, cached.expiresAt > Date() {
|
|
return cached.hash
|
|
}
|
|
|
|
guard let url = URL(string: "https://www.united.com/api/auth/anonymous-token") else { return nil }
|
|
|
|
do {
|
|
var request = URLRequest(url: url)
|
|
Self.applyUnitedBrowserHeaders(to: &request)
|
|
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
|
|
|
let (data, response) = try await session.data(for: request)
|
|
let http = response as? HTTPURLResponse
|
|
print("[UA] token HTTP status: \(http?.statusCode ?? -1), \(data.count) bytes")
|
|
guard http?.statusCode == 200 else {
|
|
if let bodyStr = String(data: data, encoding: .utf8) {
|
|
print("[UA] token body (first 500): \(bodyStr.prefix(500))")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
let dataObj = json["data"] as? [String: Any],
|
|
let tokenObj = dataObj["token"] as? [String: Any],
|
|
let hash = tokenObj["hash"] as? String else {
|
|
print("[UA] token JSON shape unexpected")
|
|
return nil
|
|
}
|
|
|
|
// Cache for 25 minutes (tokens typically last 30).
|
|
unitedToken = (hash: hash, expiresAt: Date().addingTimeInterval(25 * 60))
|
|
return hash
|
|
} catch {
|
|
print("[UA] token error: \(error)")
|
|
return nil
|
|
}
|
|
}
|
|
|
|
/// Browser-shaped headers for United. The guide notes that plain curl is
|
|
/// blocked by TLS fingerprinting; iOS `URLSession` has a different
|
|
/// fingerprint but United's Akamai rules still sniff `User-Agent` /
|
|
/// `Accept-Language`, so we mirror a desktop browser. Not a guarantee —
|
|
/// if this still fails we may need a WKWebView path like JSX.
|
|
private static func applyUnitedBrowserHeaders(to request: inout URLRequest) {
|
|
request.setValue(
|
|
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 "
|
|
+ "(KHTML, like Gecko) Version/17.4 Safari/605.1.15",
|
|
forHTTPHeaderField: "User-Agent"
|
|
)
|
|
request.setValue("en-US,en;q=0.9", forHTTPHeaderField: "Accept-Language")
|
|
request.setValue("https://www.united.com/", forHTTPHeaderField: "Referer")
|
|
request.setValue("https://www.united.com", forHTTPHeaderField: "Origin")
|
|
}
|
|
|
|
private func fetchUnitedLoad(flightNumber: String, date: Date, origin: String) async -> FlightLoad? {
|
|
guard let token = await getUnitedToken() else { return nil }
|
|
|
|
let num = stripAirlinePrefix(flightNumber)
|
|
let dateStr = dayString(from: date, originIATA: origin)
|
|
|
|
guard let url = URL(string: "https://www.united.com/api/flightstatus/upgradeListExtended?flightNumber=\(num)&flightDate=\(dateStr)&fromAirportCode=\(origin.uppercased())") else {
|
|
return nil
|
|
}
|
|
|
|
do {
|
|
var request = URLRequest(url: url)
|
|
Self.applyUnitedBrowserHeaders(to: &request)
|
|
request.setValue("bearer \(token)", forHTTPHeaderField: "x-authorization-api")
|
|
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
|
|
|
let (data, response) = try await session.data(for: request)
|
|
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
|
|
print("[UA] HTTP status: \((response as? HTTPURLResponse)?.statusCode ?? -1)")
|
|
return nil
|
|
}
|
|
|
|
guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
|
print("[UA] Failed to parse JSON")
|
|
return nil
|
|
}
|
|
|
|
// === LOGGING: Dump top-level keys and structure ===
|
|
print("[UA] ===== RESPONSE FOR UA\(num) =====")
|
|
print("[UA] Top-level keys: \(json.keys.sorted())")
|
|
|
|
if let segment = json["segment"] as? [String: Any] {
|
|
print("[UA] segment: \(segment)")
|
|
}
|
|
|
|
if let numCabins = json["numberOfCabins"] {
|
|
print("[UA] numberOfCabins: \(numCabins)")
|
|
}
|
|
|
|
// Log pbts
|
|
if let pbts = json["pbts"] as? [[String: Any]] {
|
|
print("[UA] pbts count: \(pbts.count)")
|
|
for (i, entry) in pbts.enumerated() {
|
|
print("[UA] pbts[\(i)]: \(entry)")
|
|
}
|
|
} else {
|
|
print("[UA] pbts: MISSING or wrong type")
|
|
}
|
|
|
|
// Log ALL cabin-level keys (front, middle, rear, etc.)
|
|
for key in json.keys.sorted() {
|
|
if let cabinData = json[key] as? [String: Any],
|
|
cabinData.keys.contains("standby") || cabinData.keys.contains("cleared") {
|
|
let clearedList = cabinData["cleared"] as? [[String: Any]] ?? []
|
|
let standbyListRaw = cabinData["standby"] as? [[String: Any]] ?? []
|
|
print("[UA] '\(key)' cabin: cleared=\(clearedList.count), standby=\(standbyListRaw.count)")
|
|
for pax in clearedList.prefix(3) {
|
|
print("[UA] cleared: \(pax["passengerName"] ?? "?") seat=\(pax["seatNumber"] ?? "?")")
|
|
}
|
|
for pax in standbyListRaw.prefix(3) {
|
|
print("[UA] standby: \(pax["passengerName"] ?? "?") seat=\(pax["seatNumber"] ?? "?")")
|
|
}
|
|
if clearedList.count > 3 { print("[UA] ... +\(clearedList.count - 3) more cleared") }
|
|
if standbyListRaw.count > 3 { print("[UA] ... +\(standbyListRaw.count - 3) more standby") }
|
|
}
|
|
}
|
|
|
|
// Log checkInSummaries if present
|
|
if let checkIn = json["checkInSummaries"] as? [[String: Any]] {
|
|
print("[UA] checkInSummaries count: \(checkIn.count)")
|
|
for entry in checkIn {
|
|
print("[UA] checkIn: \(entry)")
|
|
}
|
|
}
|
|
|
|
print("[UA] ===== END RESPONSE =====")
|
|
|
|
// Parse cabin loads from pbts array
|
|
var cabins: [CabinLoad] = []
|
|
if let pbts = json["pbts"] as? [[String: Any]] {
|
|
for entry in pbts {
|
|
let cabin = entry["cabin"] as? String ?? "Unknown"
|
|
let capacity = entry["capacity"] as? Int ?? 0
|
|
let booked = entry["booked"] as? Int ?? 0
|
|
let revStandby = entry["revenueStandby"] as? Int ?? 0
|
|
let sa = entry["sa"] as? Int ?? 0
|
|
let waitList = entry["waitList"] as? Int ?? 0
|
|
let jump = entry["jump"] as? Int ?? 0
|
|
let ps = entry["ps"] as? Int ?? 0
|
|
|
|
print("[UA] Cabin '\(cabin)': cap=\(capacity) booked=\(booked) sa=\(sa) revSB=\(revStandby) waitList=\(waitList) jump=\(jump) ps=\(ps)")
|
|
|
|
cabins.append(CabinLoad(
|
|
name: cabin,
|
|
capacity: capacity,
|
|
booked: booked,
|
|
revenueStandby: revStandby,
|
|
nonRevStandby: sa,
|
|
waitListCount: waitList,
|
|
jumpSeat: jump
|
|
))
|
|
}
|
|
}
|
|
|
|
// Parse standby / upgrade lists from ALL cabin sections
|
|
var standbyList: [StandbyPassenger] = []
|
|
var upgradeList: [StandbyPassenger] = []
|
|
|
|
// Check all keys that have cleared/standby sub-arrays
|
|
for key in json.keys.sorted() {
|
|
guard let cabinData = json[key] as? [String: Any],
|
|
cabinData.keys.contains("standby") || cabinData.keys.contains("cleared") else {
|
|
continue
|
|
}
|
|
|
|
let cabinName = key.capitalized
|
|
|
|
if let cleared = cabinData["cleared"] as? [[String: Any]] {
|
|
for pax in cleared {
|
|
upgradeList.append(StandbyPassenger(
|
|
order: upgradeList.count + 1,
|
|
displayName: pax["passengerName"] as? String ?? "",
|
|
cleared: true,
|
|
seat: pax["seatNumber"] as? String,
|
|
listName: cabinName
|
|
))
|
|
}
|
|
}
|
|
|
|
if let waiting = cabinData["standby"] as? [[String: Any]] {
|
|
for pax in waiting {
|
|
let seat = pax["seatNumber"] as? String
|
|
let hasSeat = seat != nil && !seat!.isEmpty
|
|
|
|
if hasSeat {
|
|
// Has a seat = already cleared from upgrade waitlist
|
|
upgradeList.append(StandbyPassenger(
|
|
order: upgradeList.count + 1,
|
|
displayName: pax["passengerName"] as? String ?? "",
|
|
cleared: true,
|
|
seat: seat,
|
|
listName: cabinName
|
|
))
|
|
} else {
|
|
// No seat = actually waiting on standby
|
|
standbyList.append(StandbyPassenger(
|
|
order: standbyList.count + 1,
|
|
displayName: pax["passengerName"] as? String ?? "",
|
|
cleared: false,
|
|
seat: nil,
|
|
listName: cabinName
|
|
))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return FlightLoad(
|
|
airlineCode: "UA",
|
|
flightNumber: "UA\(num)",
|
|
cabins: cabins,
|
|
standbyList: standbyList,
|
|
upgradeList: upgradeList,
|
|
seatAvailability: []
|
|
)
|
|
} catch {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// MARK: - American Airlines
|
|
|
|
/// AA gates the waitlist API on User-Agent version. Bump this when
|
|
/// `airlines/com.aa.android_*.apkm` is refreshed — stale versions get
|
|
/// HTTP 403 with `{"alert":{"message":"Please update your version..."}}`.
|
|
private static let aaAppVersion = "2026.14"
|
|
|
|
private func fetchAmericanLoad(
|
|
flightNumber: String,
|
|
date: Date,
|
|
origin: String,
|
|
destination: String
|
|
) async -> FlightLoad? {
|
|
let num = stripAirlinePrefix(flightNumber)
|
|
let dateStr = dayString(from: date, originIATA: origin)
|
|
|
|
var components = URLComponents(string: "https://cdn.flyaa.aa.com/api/mobile/loyalty/waitlist/v1.2")
|
|
components?.queryItems = [
|
|
URLQueryItem(name: "carrierCode", value: "AA"),
|
|
URLQueryItem(name: "flightNumber", value: num),
|
|
URLQueryItem(name: "departureDate", value: dateStr),
|
|
URLQueryItem(name: "originAirportCode", value: origin.uppercased()),
|
|
URLQueryItem(name: "destinationAirportCode", value: destination.uppercased())
|
|
]
|
|
|
|
guard let url = components?.url else {
|
|
print("[AA] Invalid URL components")
|
|
return nil
|
|
}
|
|
|
|
print("[AA] GET \(url.absoluteString)")
|
|
|
|
do {
|
|
var request = URLRequest(url: url)
|
|
request.setValue("Android/\(Self.aaAppVersion) Pixel 7|14|1080|2400|1.0|AmericanAirlines",
|
|
forHTTPHeaderField: "User-Agent")
|
|
request.setValue("MOBILE", forHTTPHeaderField: "x-clientid")
|
|
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
request.setValue(UUID().uuidString, forHTTPHeaderField: "Device-ID")
|
|
request.setValue("fs", forHTTPHeaderField: "x-referrer")
|
|
|
|
let (data, response) = try await session.data(for: request)
|
|
let http = response as? HTTPURLResponse
|
|
let status = http?.statusCode ?? -1
|
|
print("[AA] HTTP status: \(status), \(data.count) bytes")
|
|
|
|
if status != 200 {
|
|
if let bodyStr = String(data: data, encoding: .utf8) {
|
|
print("[AA] body (first 500): \(bodyStr.prefix(500))")
|
|
// Server hints when the UA version has aged out — surface it.
|
|
if status == 403, bodyStr.contains("update your version") {
|
|
print("[AA] ⚠️ User-Agent version (\(Self.aaAppVersion)) is rejected — bump aaAppVersion to match the latest APK in airlines/")
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
|
print("[AA] JSON parse failed; body (first 500): \(String(data: data, encoding: .utf8)?.prefix(500) ?? "")")
|
|
return nil
|
|
}
|
|
print("[AA] top-level keys: \(json.keys.sorted())")
|
|
|
|
guard let waitListArray = json["waitList"] as? [[String: Any]] else {
|
|
// 200 OK but no `waitList` — typical for AA Eagle 4-digit
|
|
// regional flights (marketed as AA but the mobile waitlist
|
|
// endpoint doesn't track them), or for flights whose waitlist
|
|
// hasn't opened yet (usually opens T-24h before departure).
|
|
print("[AA] No 'waitList' array in response — likely no waitlist open yet for this flight")
|
|
return nil
|
|
}
|
|
print("[AA] waitList entries: \(waitListArray.count)")
|
|
|
|
var seatAvailability: [SeatAvailability] = []
|
|
var standbyList: [StandbyPassenger] = []
|
|
var upgradeList: [StandbyPassenger] = []
|
|
|
|
for entry in waitListArray {
|
|
let listName = entry["listName"] as? String ?? "Unknown"
|
|
let seatsAvailable = entry["seatsAvailableValue"] as? Int ?? 0
|
|
let semanticColor = entry["seatsAvailableSemanticColor"] as? String ?? "success"
|
|
|
|
let label = listName == "First" ? "First Class Upgrades" : "\(listName) Seats"
|
|
|
|
seatAvailability.append(SeatAvailability(
|
|
label: label,
|
|
available: seatsAvailable,
|
|
color: SeatAvailabilityColor(rawValue: semanticColor) ?? .success
|
|
))
|
|
|
|
// Parse passengers
|
|
if let passengers = entry["passengers"] as? [[String: Any]] {
|
|
for pax in passengers {
|
|
let order = pax["order"] as? Int ?? 0
|
|
let displayName = pax["displayName"] as? String ?? ""
|
|
let cleared = pax["cleared"] as? Bool ?? false
|
|
let seat = pax["seat"] as? String
|
|
|
|
let passenger = StandbyPassenger(
|
|
order: order,
|
|
displayName: displayName,
|
|
cleared: cleared,
|
|
seat: seat,
|
|
listName: listName
|
|
)
|
|
|
|
if listName.lowercased() == "standby" {
|
|
standbyList.append(passenger)
|
|
} else {
|
|
upgradeList.append(passenger)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
print("[AA] parsed seatAvailability=\(seatAvailability.count) standby=\(standbyList.count) upgrade=\(upgradeList.count)")
|
|
return FlightLoad(
|
|
airlineCode: "AA",
|
|
flightNumber: "AA\(num)",
|
|
cabins: [],
|
|
standbyList: standbyList,
|
|
upgradeList: upgradeList,
|
|
seatAvailability: seatAvailability
|
|
)
|
|
} catch {
|
|
print("[AA] error: \(error)")
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// MARK: - Spirit Airlines
|
|
|
|
private func fetchSpiritStatus(origin: String, destination: String, date: Date) async -> FlightLoad? {
|
|
guard let url = URL(string: "https://api.spirit.com/customermobileprod/2.8.0/v3/GetFlightInfoBI") else {
|
|
print("[NK] Invalid URL")
|
|
return nil
|
|
}
|
|
|
|
let dateStr = dayString(from: date, originIATA: origin)
|
|
let body: [String: String] = [
|
|
"departureStation": origin.uppercased(),
|
|
"arrivalStation": destination.uppercased(),
|
|
"departureDate": dateStr
|
|
]
|
|
|
|
print("[NK] POST \(url) body: \(body)")
|
|
|
|
do {
|
|
var request = URLRequest(url: url)
|
|
request.httpMethod = "POST"
|
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
request.setValue("c6567af50d544dfbb3bc5dd99c6bb177", forHTTPHeaderField: "Ocp-Apim-Subscription-Key")
|
|
request.setValue("Android", forHTTPHeaderField: "Platform")
|
|
request.httpBody = try JSONSerialization.data(withJSONObject: body)
|
|
|
|
let (data, response) = try await session.data(for: request)
|
|
let http = response as? HTTPURLResponse
|
|
print("[NK] HTTP status: \(http?.statusCode ?? -1)")
|
|
|
|
if let bodyStr = String(data: data, encoding: .utf8) {
|
|
print("[NK] Response body: \(bodyStr.prefix(500))")
|
|
}
|
|
|
|
guard http?.statusCode == 200 else {
|
|
print("[NK] Non-200 response")
|
|
return nil
|
|
}
|
|
|
|
// Spirit is a ULCC with no standby program.
|
|
return FlightLoad(
|
|
airlineCode: "NK",
|
|
flightNumber: "NK",
|
|
cabins: [],
|
|
standbyList: [],
|
|
upgradeList: [],
|
|
seatAvailability: []
|
|
)
|
|
} catch {
|
|
print("[NK] Error: \(error)")
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// MARK: - Korean Air
|
|
|
|
private func fetchKoreanAirLoad(
|
|
flightNumber: String,
|
|
date: Date,
|
|
origin: String,
|
|
destination: String
|
|
) async -> FlightLoad? {
|
|
guard let url = URL(string: "https://www.koreanair.com/api/et/ibeSupport/flightSeatCount") else {
|
|
return nil
|
|
}
|
|
|
|
let num = stripAirlinePrefix(flightNumber)
|
|
let dateStr = compactDayString(from: date, originIATA: origin)
|
|
|
|
let body: [String: String] = [
|
|
"carrierCode": "KE",
|
|
"flightNumber": num,
|
|
"departureAirport": origin.uppercased(),
|
|
"arrivalAirport": destination.uppercased(),
|
|
"departureDate": dateStr
|
|
]
|
|
|
|
print("[KE] POST \(url) body: \(body)")
|
|
|
|
do {
|
|
var request = URLRequest(url: url)
|
|
request.httpMethod = "POST"
|
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
|
request.setValue("pc", forHTTPHeaderField: "channel")
|
|
request.httpBody = try JSONSerialization.data(withJSONObject: body)
|
|
|
|
let (data, response) = try await session.data(for: request)
|
|
let http = response as? HTTPURLResponse
|
|
print("[KE] HTTP status: \(http?.statusCode ?? -1), \(data.count) bytes")
|
|
|
|
if let bodyStr = String(data: data, encoding: .utf8) {
|
|
print("[KE] body (first 1200): \(bodyStr.prefix(1200))")
|
|
}
|
|
|
|
guard http?.statusCode == 200 else { return nil }
|
|
guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
|
print("[KE] JSON parse failed")
|
|
return nil
|
|
}
|
|
|
|
// Guide doesn't document the response shape; try a few likely keys
|
|
// and fall back to scanning any top-level container for Int values
|
|
// named like "seatCount". Log what we see so the real shape is
|
|
// visible in the Xcode console on first run.
|
|
print("[KE] top-level keys: \(json.keys.sorted())")
|
|
|
|
let seatCount = (json["seatCount"] as? Int)
|
|
?? (json["availableSeatCount"] as? Int)
|
|
?? (json["totalSeatCount"] as? Int)
|
|
?? ((json["data"] as? [String: Any])?["seatCount"] as? Int)
|
|
?? 0
|
|
|
|
print("[KE] parsed seatCount=\(seatCount)")
|
|
|
|
// KE doesn't expose capacity/booked — report what we know as
|
|
// non-revenue-standby availability in an Economy-labeled cabin
|
|
// (matches how we presented this previously).
|
|
let cabin = CabinLoad(
|
|
name: "Economy",
|
|
capacity: 0,
|
|
booked: 0,
|
|
revenueStandby: 0,
|
|
nonRevStandby: seatCount
|
|
)
|
|
|
|
return FlightLoad(
|
|
airlineCode: "KE",
|
|
flightNumber: "KE\(num)",
|
|
cabins: [cabin],
|
|
standbyList: [],
|
|
upgradeList: [],
|
|
seatAvailability: []
|
|
)
|
|
} catch {
|
|
print("[KE] error: \(error)")
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// MARK: - JetBlue
|
|
|
|
private func fetchJetBlueStatus(flightNumber: String, date: Date, origin: String) async -> FlightLoad? {
|
|
let num = stripAirlinePrefix(flightNumber)
|
|
let dateStr = dayString(from: date, originIATA: origin)
|
|
|
|
guard let url = URL(string: "https://az-api.jetblue.com/flight-status/get-by-number?number=\(num)&date=\(dateStr)") else {
|
|
print("[B6] Invalid URL")
|
|
return nil
|
|
}
|
|
|
|
print("[B6] GET \(url)")
|
|
|
|
do {
|
|
var request = URLRequest(url: url)
|
|
request.setValue("49fc015f1ba44abf892d2b8961612378", forHTTPHeaderField: "apikey")
|
|
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
|
|
|
let (data, response) = try await session.data(for: request)
|
|
let http = response as? HTTPURLResponse
|
|
print("[B6] HTTP status: \(http?.statusCode ?? -1)")
|
|
|
|
if let bodyStr = String(data: data, encoding: .utf8) {
|
|
print("[B6] Response: \(bodyStr.prefix(800))")
|
|
}
|
|
|
|
guard http?.statusCode == 200 else { return nil }
|
|
|
|
guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
let flights = json["flights"] as? [[String: Any]],
|
|
let flight = flights.first,
|
|
let legs = flight["legs"] as? [[String: Any]],
|
|
let leg = legs.first else {
|
|
print("[B6] Failed to parse flight data")
|
|
return nil
|
|
}
|
|
|
|
let flightNo = leg["flightNo"] as? String ?? num
|
|
let status = leg["flightStatus"] as? String ?? "Unknown"
|
|
print("[B6] Flight B6\(flightNo) status: \(status)")
|
|
|
|
// JetBlue flight status only — no seat/standby data without check-in session
|
|
return FlightLoad(
|
|
airlineCode: "B6",
|
|
flightNumber: "B6\(flightNo)",
|
|
cabins: [],
|
|
standbyList: [],
|
|
upgradeList: [],
|
|
seatAvailability: []
|
|
)
|
|
} catch {
|
|
print("[B6] Error: \(error)")
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// MARK: - Alaska Airlines
|
|
|
|
/// Static APIM key extracted from the Alaska mobile app, per
|
|
/// AIRLINE_INTEGRATION_GUIDE.md. Accepts `confirmationCode: null` on the
|
|
/// `/seats/waitlist` POST, so we get standby + upgrade lists without a PNR.
|
|
private static let alaskaAPIMKey = "de1d0ff837444468a5ea868945aab738"
|
|
|
|
private func fetchAlaskaLoad(
|
|
flightNumber: String,
|
|
date: Date,
|
|
origin: String,
|
|
destination: String
|
|
) async -> FlightLoad? {
|
|
let num = stripAirlinePrefix(flightNumber)
|
|
let dateStr = dayString(from: date, originIATA: origin)
|
|
let upperOrigin = origin.uppercased()
|
|
let upperDestination = destination.uppercased()
|
|
|
|
print("[AS] Fetching Alaska load for AS\(num) \(upperOrigin)->\(upperDestination) on \(dateStr)")
|
|
|
|
// Run seatmap + waitlist in parallel; either may fail independently.
|
|
async let seatmap = fetchAlaskaSeatmap(
|
|
flightNumber: num,
|
|
origin: upperOrigin,
|
|
destination: upperDestination,
|
|
date: dateStr
|
|
)
|
|
async let waitlist = fetchAlaskaWaitlist(
|
|
flightNumber: num,
|
|
origin: upperOrigin,
|
|
date: dateStr
|
|
)
|
|
|
|
let cabinsFromMap = await seatmap
|
|
let waitlistResult = await waitlist
|
|
let cabinsFromWaitlist = waitlistResult.0
|
|
let standbyList = waitlistResult.1
|
|
let upgradeList = waitlistResult.2
|
|
|
|
// Prefer waitlist's `Authorized` cabin capacity (real FlightLoad numbers)
|
|
// over seatmap's `AvailableSeats` (count of open seats only).
|
|
let cabins = !cabinsFromWaitlist.isEmpty ? cabinsFromWaitlist : cabinsFromMap
|
|
|
|
if cabins.isEmpty && standbyList.isEmpty && upgradeList.isEmpty {
|
|
print("[AS] No data returned from seatmap or waitlist endpoints")
|
|
return nil
|
|
}
|
|
|
|
return FlightLoad(
|
|
airlineCode: "AS",
|
|
flightNumber: "AS\(num)",
|
|
cabins: cabins,
|
|
standbyList: standbyList,
|
|
upgradeList: upgradeList,
|
|
seatAvailability: []
|
|
)
|
|
}
|
|
|
|
/// GET /1/guestservices/customermobile/viewseatmap/seatmap
|
|
/// Returns per-seat status + `AvailableSeats` count per cabin section.
|
|
private func fetchAlaskaSeatmap(
|
|
flightNumber: String,
|
|
origin: String,
|
|
destination: String,
|
|
date: String
|
|
) async -> [CabinLoad] {
|
|
var components = URLComponents(string: "https://apis.alaskaair.com/1/guestservices/customermobile/viewseatmap/seatmap")
|
|
components?.queryItems = [
|
|
URLQueryItem(name: "flightnumber", value: flightNumber),
|
|
URLQueryItem(name: "departureairport", value: origin),
|
|
URLQueryItem(name: "arrivalairport", value: destination),
|
|
URLQueryItem(name: "departuredate", value: date)
|
|
]
|
|
guard let url = components?.url else {
|
|
print("[AS] seatmap: invalid URL")
|
|
return []
|
|
}
|
|
|
|
print("[AS] GET \(url)")
|
|
|
|
do {
|
|
var request = URLRequest(url: url)
|
|
request.setValue(Self.alaskaAPIMKey, forHTTPHeaderField: "Ocp-Apim-Subscription-Key")
|
|
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
|
|
|
let (data, response) = try await session.data(for: request)
|
|
let http = response as? HTTPURLResponse
|
|
print("[AS] seatmap HTTP status: \(http?.statusCode ?? -1), \(data.count) bytes")
|
|
|
|
if let bodyStr = String(data: data, encoding: .utf8) {
|
|
print("[AS] seatmap body (first 1200): \(bodyStr.prefix(1200))")
|
|
}
|
|
|
|
guard http?.statusCode == 200 else { return [] }
|
|
guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { return [] }
|
|
|
|
// Per the guide, the response carries `AvailableSeats` per cabin section.
|
|
// The exact wrapper differs by route; we walk the top level looking for
|
|
// anything that has {AvailableSeats, ...} or a list of cabin sections.
|
|
var cabins: [CabinLoad] = []
|
|
|
|
// Common shapes: { Cabins: [{ Name, AvailableSeats, ... }] } or
|
|
// { Sections: [...] } or the cabin fields inline at root.
|
|
if let cabinArr = (json["Cabins"] as? [[String: Any]])
|
|
?? (json["cabins"] as? [[String: Any]])
|
|
?? (json["Sections"] as? [[String: Any]]) {
|
|
for entry in cabinArr {
|
|
let name = (entry["Name"] as? String)
|
|
?? (entry["CabinName"] as? String)
|
|
?? (entry["Description"] as? String)
|
|
?? "Cabin"
|
|
let available = (entry["AvailableSeats"] as? Int)
|
|
?? (entry["availableSeats"] as? Int)
|
|
?? 0
|
|
// Capacity is best-effort; fall back to available so
|
|
// loadFactor stays sane when we only have open seats.
|
|
let capacity = (entry["TotalSeats"] as? Int)
|
|
?? (entry["Capacity"] as? Int)
|
|
?? available
|
|
let booked = max(0, capacity - available)
|
|
cabins.append(CabinLoad(
|
|
name: name,
|
|
capacity: capacity,
|
|
booked: booked,
|
|
revenueStandby: 0,
|
|
nonRevStandby: 0
|
|
))
|
|
}
|
|
}
|
|
|
|
print("[AS] seatmap parsed \(cabins.count) cabins")
|
|
return cabins
|
|
} catch {
|
|
print("[AS] seatmap error: \(error)")
|
|
return []
|
|
}
|
|
}
|
|
|
|
/// POST /1/guestservices/customermobile/seats/waitlist
|
|
/// Returns StandbyList + UpgradeList with FlightLoad.Authorized (capacity)
|
|
/// and passenger names. `confirmationCode: null` is accepted.
|
|
private func fetchAlaskaWaitlist(
|
|
flightNumber: String,
|
|
origin: String,
|
|
date: String
|
|
) async -> ([CabinLoad], [StandbyPassenger], [StandbyPassenger]) {
|
|
guard let url = URL(string: "https://apis.alaskaair.com/1/guestservices/customermobile/seats/waitlist") else {
|
|
print("[AS] waitlist: invalid URL")
|
|
return ([], [], [])
|
|
}
|
|
|
|
let body: [String: Any?] = [
|
|
"marketedByAirlineCode": "AS",
|
|
"departureAirportCode": origin,
|
|
"departureLocalDate": date,
|
|
"flightNumber": flightNumber,
|
|
"confirmationCode": NSNull()
|
|
]
|
|
|
|
print("[AS] POST \(url) body flight=\(flightNumber) origin=\(origin) date=\(date)")
|
|
|
|
do {
|
|
var request = URLRequest(url: url)
|
|
request.httpMethod = "POST"
|
|
request.setValue(Self.alaskaAPIMKey, forHTTPHeaderField: "Ocp-Apim-Subscription-Key")
|
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
|
request.httpBody = try JSONSerialization.data(withJSONObject: body.compactMapValues { $0 as Any? })
|
|
|
|
let (data, response) = try await session.data(for: request)
|
|
let http = response as? HTTPURLResponse
|
|
print("[AS] waitlist HTTP status: \(http?.statusCode ?? -1), \(data.count) bytes")
|
|
|
|
if let bodyStr = String(data: data, encoding: .utf8) {
|
|
print("[AS] waitlist body (first 1500): \(bodyStr.prefix(1500))")
|
|
}
|
|
|
|
guard http?.statusCode == 200 else { return ([], [], []) }
|
|
guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
|
return ([], [], [])
|
|
}
|
|
|
|
let (mainCap, mainPax) = parseAlaskaWaitlistSection(json["StandbyList"], listLabel: "Standby")
|
|
let (upgradeCap, upgradePax) = parseAlaskaWaitlistSection(json["UpgradeList"], listLabel: "Upgrade")
|
|
|
|
var cabins: [CabinLoad] = []
|
|
if mainCap > 0 {
|
|
cabins.append(CabinLoad(
|
|
name: "Main",
|
|
capacity: mainCap,
|
|
booked: 0,
|
|
revenueStandby: 0,
|
|
nonRevStandby: 0
|
|
))
|
|
}
|
|
if upgradeCap > 0 {
|
|
cabins.append(CabinLoad(
|
|
name: "First",
|
|
capacity: upgradeCap,
|
|
booked: 0,
|
|
revenueStandby: 0,
|
|
nonRevStandby: 0
|
|
))
|
|
}
|
|
|
|
return (cabins, mainPax, upgradePax)
|
|
} catch {
|
|
print("[AS] waitlist error: \(error)")
|
|
return ([], [], [])
|
|
}
|
|
}
|
|
|
|
/// Parse one of the waitlist sections (StandbyList / UpgradeList).
|
|
/// Returns (cabinCapacity, passengers).
|
|
private func parseAlaskaWaitlistSection(_ raw: Any?, listLabel: String) -> (Int, [StandbyPassenger]) {
|
|
guard let section = raw as? [String: Any] else { return (0, []) }
|
|
|
|
let flightLoad = section["FlightLoad"] as? [String: Any] ?? [:]
|
|
let authorized = (flightLoad["Authorized"] as? Int) ?? 0
|
|
|
|
var passengers: [StandbyPassenger] = []
|
|
if let paxArr = section["Passengers"] as? [[String: Any]] {
|
|
for pax in paxArr {
|
|
let name = (pax["DisplayName"] as? String) ?? ""
|
|
let position = (pax["Position"] as? Int) ?? (passengers.count + 1)
|
|
let seat = pax["Seat"] as? String
|
|
let upgraded = (pax["UpgradedToPC"] as? Bool) ?? false
|
|
let cleared = upgraded || (seat != nil && !(seat?.isEmpty ?? true))
|
|
passengers.append(StandbyPassenger(
|
|
order: position,
|
|
displayName: name,
|
|
cleared: cleared,
|
|
seat: seat,
|
|
listName: listLabel
|
|
))
|
|
}
|
|
}
|
|
|
|
print("[AS] \(listLabel) section: authorized=\(authorized), passengers=\(passengers.count)")
|
|
return (authorized, passengers)
|
|
}
|
|
|
|
// MARK: - Emirates
|
|
|
|
private func fetchEmiratesStatus(flightNumber: String, date: Date, origin: String) async -> FlightLoad? {
|
|
let num = stripAirlinePrefix(flightNumber)
|
|
let dateStr = dayString(from: date, originIATA: origin)
|
|
|
|
// Guide sample uses `flight=221` unpadded; padding was an unverified
|
|
// guess and is now removed to match the documented call exactly.
|
|
guard let url = URL(string: "https://www.emirates.com/service/flight-status?departureDate=\(dateStr)&flight=\(num)") else {
|
|
print("[EK] Invalid URL")
|
|
return nil
|
|
}
|
|
|
|
print("[EK] GET \(url)")
|
|
|
|
do {
|
|
var request = URLRequest(url: url)
|
|
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
|
|
|
let (data, response) = try await session.data(for: request)
|
|
let http = response as? HTTPURLResponse
|
|
print("[EK] HTTP status: \(http?.statusCode ?? -1)")
|
|
|
|
if let bodyStr = String(data: data, encoding: .utf8) {
|
|
print("[EK] Response: \(bodyStr.prefix(800))")
|
|
}
|
|
|
|
guard http?.statusCode == 200 else { return nil }
|
|
|
|
guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
let results = json["results"] as? [[String: Any]],
|
|
let flight = results.first else {
|
|
print("[EK] Failed to parse flight data")
|
|
return nil
|
|
}
|
|
|
|
let flightNo = flight["flightNumber"] as? String ?? num
|
|
print("[EK] Flight EK\(flightNo) found")
|
|
|
|
// Emirates flight status only — seat/load data requires PNR
|
|
return FlightLoad(
|
|
airlineCode: "EK",
|
|
flightNumber: "EK\(flightNo)",
|
|
cabins: [],
|
|
standbyList: [],
|
|
upgradeList: [],
|
|
seatAvailability: []
|
|
)
|
|
} catch {
|
|
print("[EK] Error: \(error)")
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// MARK: - JSX (JetSuiteX)
|
|
|
|
private func fetchJSXLoad(
|
|
flightNumber: String,
|
|
date: Date,
|
|
origin: String,
|
|
destination: String,
|
|
departureTime: String?
|
|
) async -> FlightLoad? {
|
|
let dateStr = dayString(from: date, originIATA: origin)
|
|
let num = stripAirlinePrefix(flightNumber)
|
|
let upperOrigin = origin.uppercased()
|
|
let upperDestination = destination.uppercased()
|
|
|
|
print("[XE] Fetching JSX for XE\(num) \(upperOrigin)->\(upperDestination) on \(dateStr)"
|
|
+ (departureTime.map { " @ \($0)" } ?? ""))
|
|
|
|
let fetcher = await JSXWebViewFetcher()
|
|
let result = await fetcher.fetchAvailability(
|
|
origin: upperOrigin,
|
|
destination: upperDestination,
|
|
date: dateStr
|
|
)
|
|
|
|
if let error = result.error {
|
|
print("[XE] JSX flow failed: \(error)")
|
|
return nil
|
|
}
|
|
|
|
print("[XE] JSX returned \(result.flights.count) unique flights for \(upperOrigin)|\(upperDestination):")
|
|
for f in result.flights {
|
|
let low = f.lowestFareTotal.map { "$\(Int($0))" } ?? "n/a"
|
|
let depHHmm = jsxLocalTimeHHmm(f.departureLocal)
|
|
print("[XE] - \(f.flightNumber) \(f.departureLocal) (\(depHHmm)) seats=\(f.totalAvailable) low=\(low)")
|
|
}
|
|
|
|
let capacity = 30 // JSX ERJ-145 approximate capacity
|
|
|
|
// Preferred path: per-flight data from /search/simple.
|
|
if !result.flights.isEmpty {
|
|
let targetDigits = num.filter(\.isNumber)
|
|
|
|
// Primary: match by digits-only flight number. This works for
|
|
// genuinely-distinct flight numbers (XE280 vs XE292 vs XE290).
|
|
let byNumber = result.flights.filter {
|
|
$0.flightNumber.filter(\.isNumber) == targetDigits
|
|
}
|
|
|
|
// If the number alone identifies a unique flight, use it.
|
|
if byNumber.count == 1 {
|
|
let flight = byNumber[0]
|
|
print("[XE] MATCH by flight number: \(flight.flightNumber) seats=\(flight.totalAvailable)")
|
|
return makeJSXFlightLoad(flight: flight, capacity: capacity)
|
|
}
|
|
|
|
// Secondary: if multiple flights share the same number (which
|
|
// happens when the flightconnections scraper collapses distinct
|
|
// departures under one flightnumber row), or if the number
|
|
// matches nothing, use the caller's departureTime as the tie
|
|
// breaker. JSXFlight.departureLocal is "YYYY-MM-DDTHH:mm:ss";
|
|
// FlightSchedule.departureTime is "HH:mm".
|
|
if let wantHHmm = departureTime, !wantHHmm.isEmpty {
|
|
let pool = byNumber.isEmpty ? result.flights : byNumber
|
|
if let flight = pool.first(where: { jsxLocalTimeHHmm($0.departureLocal) == wantHHmm }) {
|
|
print("[XE] MATCH by departureTime \(wantHHmm): \(flight.flightNumber) seats=\(flight.totalAvailable)"
|
|
+ (byNumber.isEmpty ? " (ignored flight number)" : " (tie-break among \(byNumber.count) same-number flights)"))
|
|
return makeJSXFlightLoad(flight: flight, capacity: capacity)
|
|
}
|
|
print("[XE] departureTime \(wantHHmm) did not match any of \(result.flights.count) flights")
|
|
}
|
|
|
|
// Last resort in the primary path: if byNumber has anything,
|
|
// take the first. Report explicitly so we can tell this apart
|
|
// from a clean match in the logs.
|
|
if let flight = byNumber.first {
|
|
print("[XE] MATCH (ambiguous first-of-\(byNumber.count)): \(flight.flightNumber) seats=\(flight.totalAvailable)")
|
|
return makeJSXFlightLoad(flight: flight, capacity: capacity)
|
|
}
|
|
|
|
print("[XE] Flight XE\(num) not present in \(result.flights.count) results (no number or time match)")
|
|
return nil
|
|
}
|
|
|
|
// Graceful degradation: /search/simple was unreachable but we have
|
|
// the /lowfare/estimate day-total, which gives us at least
|
|
// "how many seats are available somewhere on the route today".
|
|
// We can't attribute that to a specific flight, so we show it as
|
|
// a route-level cabin so the UI isn't empty.
|
|
if let lowFare = result.lowFareFallback {
|
|
print("[XE] Falling back to low-fare estimate: \(lowFare.available) seats available on \(lowFare.date)")
|
|
let booked = max(0, capacity - lowFare.available)
|
|
return FlightLoad(
|
|
airlineCode: "XE",
|
|
flightNumber: "XE\(num)",
|
|
cabins: [
|
|
CabinLoad(
|
|
name: "Route (day total)",
|
|
capacity: capacity,
|
|
booked: booked,
|
|
revenueStandby: 0,
|
|
nonRevStandby: 0
|
|
)
|
|
],
|
|
standbyList: [],
|
|
upgradeList: [],
|
|
seatAvailability: []
|
|
)
|
|
}
|
|
|
|
print("[XE] No per-flight data and no low-fare fallback; giving up")
|
|
return nil
|
|
}
|
|
|
|
// MARK: - JSX helpers
|
|
|
|
/// Build a single-cabin `FlightLoad` from a `JSXFlight`. Factored out so
|
|
/// all the match paths in `fetchJSXLoad` return the same shape.
|
|
private func makeJSXFlightLoad(flight: JSXFlight, capacity: Int) -> FlightLoad {
|
|
let booked = max(0, capacity - flight.totalAvailable)
|
|
return FlightLoad(
|
|
airlineCode: "XE",
|
|
flightNumber: flight.flightNumber,
|
|
cabins: [
|
|
CabinLoad(
|
|
name: "Cabin",
|
|
capacity: capacity,
|
|
booked: booked,
|
|
revenueStandby: 0,
|
|
nonRevStandby: 0
|
|
)
|
|
],
|
|
standbyList: [],
|
|
upgradeList: [],
|
|
seatAvailability: []
|
|
)
|
|
}
|
|
|
|
/// Extract "HH:mm" from a JSX local time string like
|
|
/// "2026-04-15T17:35:00". Returns "" on malformed input.
|
|
private func jsxLocalTimeHHmm(_ local: String) -> String {
|
|
// Expect "YYYY-MM-DDTHH:mm:ss" — the HH:mm starts at index 11.
|
|
guard let tIdx = local.firstIndex(of: "T") else { return "" }
|
|
let after = local.index(after: tIdx)
|
|
guard local.distance(from: after, to: local.endIndex) >= 5 else { return "" }
|
|
let end = local.index(after, offsetBy: 5)
|
|
return String(local[after..<end])
|
|
}
|
|
|
|
// MARK: - Helpers
|
|
|
|
/// Strip any airline prefix from a flight number string.
|
|
/// "AA 2238" -> "2238", "UA2238" -> "2238", "2238" -> "2238"
|
|
private func stripAirlinePrefix(_ raw: String) -> String {
|
|
let trimmed = raw.trimmingCharacters(in: .whitespaces)
|
|
|
|
// Drop leading letters and any space to get just the numeric portion
|
|
var start = trimmed.startIndex
|
|
while start < trimmed.endIndex && (trimmed[start].isLetter || trimmed[start] == " ") {
|
|
start = trimmed.index(after: start)
|
|
}
|
|
|
|
let result = String(trimmed[start...])
|
|
return result.isEmpty ? trimmed : result
|
|
}
|
|
|
|
/// "yyyy-MM-dd" formatter for United, American, Spirit.
|
|
/// NOTE: this is UTC-pinned and will cross the day boundary for users in
|
|
/// non-UTC timezones. Prefer `dayString(from:originIATA:)` which resolves
|
|
/// the departure airport's approximate local timezone.
|
|
private static let dashDateFormatter: DateFormatter = {
|
|
let f = DateFormatter()
|
|
f.dateFormat = "yyyy-MM-dd"
|
|
f.locale = Locale(identifier: "en_US_POSIX")
|
|
f.timeZone = TimeZone(identifier: "UTC")
|
|
return f
|
|
}()
|
|
|
|
/// Format a `Date` as "yyyy-MM-dd" in the *departure airport's* local
|
|
/// timezone. Airlines (JSX especially) interpret the day string as the
|
|
/// calendar day at the departure airport — not UTC, not the user's locale.
|
|
///
|
|
/// Timezone resolution strategy:
|
|
/// 1. Look up the airport's longitude in the bundled airports.json.
|
|
/// 2. Approximate the offset as `round(lng / 15)` hours (15° ≈ 1hr).
|
|
/// 3. Fall back to `TimeZone.current` if the airport isn't in the DB.
|
|
///
|
|
/// The 15°/hour approximation ignores political timezone boundaries and
|
|
/// DST, but it's correct to within an hour of the real offset — plenty
|
|
/// precise for *which calendar day* the `Date` falls on.
|
|
private func dayString(from date: Date, originIATA: String) -> String {
|
|
let f = DateFormatter()
|
|
f.dateFormat = "yyyy-MM-dd"
|
|
f.locale = Locale(identifier: "en_US_POSIX")
|
|
f.timeZone = resolveTimeZone(forIATA: originIATA)
|
|
return f.string(from: date)
|
|
}
|
|
|
|
/// Same as above but for Korean Air's compact "yyyyMMdd" format.
|
|
private func compactDayString(from date: Date, originIATA: String) -> String {
|
|
let f = DateFormatter()
|
|
f.dateFormat = "yyyyMMdd"
|
|
f.locale = Locale(identifier: "en_US_POSIX")
|
|
f.timeZone = resolveTimeZone(forIATA: originIATA)
|
|
return f.string(from: date)
|
|
}
|
|
|
|
private func resolveTimeZone(forIATA iata: String) -> TimeZone {
|
|
guard let db = airportDatabase,
|
|
let airport = db.airport(byIATA: iata.uppercased()) else {
|
|
print("[TZ] \(iata): airport not in DB, falling back to TimeZone.current (\(TimeZone.current.identifier))")
|
|
return .current
|
|
}
|
|
// Round lng/15 to nearest hour. Clamp to [-12, 14] (real-world range).
|
|
let rawHours = (airport.lng / 15.0).rounded()
|
|
let hours = max(-12, min(14, Int(rawHours)))
|
|
let seconds = hours * 3600
|
|
let tz = TimeZone(secondsFromGMT: seconds) ?? .current
|
|
print("[TZ] \(iata) (lng=\(airport.lng)) → offset=\(hours)h (\(tz.identifier))")
|
|
return tz
|
|
}
|
|
|
|
/// "yyyyMMdd" formatter for Korean Air
|
|
private static let compactDateFormatter: DateFormatter = {
|
|
let f = DateFormatter()
|
|
f.dateFormat = "yyyyMMdd"
|
|
f.locale = Locale(identifier: "en_US_POSIX")
|
|
f.timeZone = TimeZone(identifier: "UTC")
|
|
return f
|
|
}()
|
|
}
|