After a long debug, the working approach for fetching per-flight
availability from JSX in WKWebView is a direct fetch() POST to
/api/nsk/v4/availability/search/simple from inside the loaded
jsx.com page context, using the anonymous auth token from
sessionStorage["navitaire.digital.token"]. Confirmed end-to-end on
a real iOS device: returns status 200 with the full 14 KB payload,
parses into per-flight JSXFlight objects with correct per-class
seat counts (e.g. XE286 = 6 seats = 3 Hop-On + 3 All-In).
Architecture:
- JSXWebViewFetcher drives the jsx.com SPA through 18 step-by-step
verified phases: create WKWebView, navigate, install passive
PerformanceObserver, dismiss Osano, select One Way, open origin
station picker and select, open destination picker and select,
open depart datepicker (polling for day cells to render), click
the target day cell by aria-label, click the picker's DONE
button to commit, force Angular form revalidation, then fire
the POST directly from the page context.
- The POST attempt is wrapped in a fallback chain: if the direct
fetch fails, try walking __ngContext__ to find the minified-name
flight-search component ("Me" in the current build) by shape
(beginDate + origin + destination + search method) and call
search() directly, then poll Angular's own state for the parsed
availability response. Final fallback is a direct GET to
/api/nsk/v1/availability/lowfare/estimate which returns a
day-total count when all per-flight paths fail.
- JSXSearchResult.flights contains one JSXFlight per unique
journey in data.results[].trips[].journeysAvailableByMarket,
with per-class breakdowns joined against data.faresAvailable.
- Every step has an action + one or more post-condition
verifications that log independently. Step failures dump
action data fields, page state, error markers, and any
PerformanceObserver resource entries so the next iteration
has ground truth, not guesses.
Known environment limitation:
- iOS Simulator CANNOT reach POST /availability/search/simple.
Simulator WebKit runs against macOS's CFNetwork stack, which
Akamai's per-endpoint protection tier treats as a different
TLS/H2 client from real iOS Safari. Every in-page or native
request (fetch, XHR, URLSession with cookies from the WKWebView
store) fails with TypeError: Load failed / error -1005 on that
specific endpoint. Other api.jsx.com endpoints (token, graph/*,
lowfare/estimate) work fine from the simulator because they're
in a looser Akamai group. On real iOS hardware the POST goes
through with status 200.
AirlineLoadService.fetchJSXLoad now threads departureTime into the
XE-specific path so the caller can disambiguate multiple flights
with the same number. Match order: (1) exact flight number match
if unique, (2) departureTime tie-break if multiple, (3) first
same-number flight as last resort. Each branch logs which match
strategy won so caller ambiguity shows up in the log.
FlightLoadDetailView logs full tap metadata (id, flight number,
extracted number, departureTime, route) and received load
(flight number, total available, total capacity) so the
fetch-to-display data flow is traceable end-to-end per tap.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
435 lines
15 KiB
Swift
435 lines
15 KiB
Swift
import SwiftUI
|
|
|
|
struct FlightLoadDetailView: View {
|
|
let schedule: FlightSchedule
|
|
let departureCode: String
|
|
let arrivalCode: String
|
|
let date: Date
|
|
let loadService: AirlineLoadService
|
|
|
|
@Environment(\.dismiss) private var dismiss
|
|
@State private var load: FlightLoad?
|
|
@State private var isLoading = true
|
|
@State private var error: String?
|
|
@State private var isUpgradeListExpanded = false
|
|
@State private var isStandbyListExpanded = false
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
ZStack {
|
|
FlightTheme.background
|
|
.ignoresSafeArea()
|
|
|
|
if isLoading {
|
|
ProgressView()
|
|
.tint(FlightTheme.accent)
|
|
} else if let error {
|
|
errorView(error)
|
|
} else if schedule.airline.iata.uppercased() == "NK" {
|
|
spiritUnavailableView
|
|
} else if let load {
|
|
loadContent(load)
|
|
} else {
|
|
unsupportedAirlineView
|
|
}
|
|
}
|
|
.navigationTitle("Flight Load")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .cancellationAction) {
|
|
Button {
|
|
dismiss()
|
|
} label: {
|
|
Image(systemName: "xmark")
|
|
.font(.subheadline.weight(.semibold))
|
|
.foregroundStyle(FlightTheme.textSecondary)
|
|
}
|
|
}
|
|
}
|
|
.task {
|
|
await fetchLoadData()
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Data Fetching
|
|
|
|
private func fetchLoadData() async {
|
|
isLoading = true
|
|
error = nil
|
|
|
|
let flightNum = extractFlightNumber(from: schedule.flightNumber)
|
|
|
|
print("[FlightLoadDetailView] TAP id=\(schedule.id.uuidString.prefix(8))"
|
|
+ " airline=\(schedule.airline.iata)"
|
|
+ " scheduleFlightNumber='\(schedule.flightNumber)'"
|
|
+ " extracted='\(flightNum)'"
|
|
+ " departureTime=\(schedule.departureTime)"
|
|
+ " arrivalTime=\(schedule.arrivalTime)"
|
|
+ " \(departureCode)->\(arrivalCode)")
|
|
|
|
let result = await loadService.fetchLoad(
|
|
airlineCode: schedule.airline.iata,
|
|
flightNumber: flightNum,
|
|
date: date,
|
|
origin: departureCode,
|
|
destination: arrivalCode,
|
|
departureTime: schedule.departureTime
|
|
)
|
|
load = result
|
|
|
|
if let l = result {
|
|
print("[FlightLoadDetailView] RECEIVED id=\(schedule.id.uuidString.prefix(8))"
|
|
+ " load.flightNumber=\(l.flightNumber)"
|
|
+ " totalAvailable=\(l.totalAvailable)"
|
|
+ " totalCapacity=\(l.totalCapacity)")
|
|
} else {
|
|
print("[FlightLoadDetailView] RECEIVED id=\(schedule.id.uuidString.prefix(8)) load=nil")
|
|
}
|
|
|
|
isLoading = false
|
|
}
|
|
|
|
// MARK: - Flight Number Extraction
|
|
|
|
/// Strips the airline prefix from a flight number.
|
|
/// "AA 2209" -> "2209", "UA2238" -> "2238", "2209" -> "2209"
|
|
private func extractFlightNumber(from raw: String) -> String {
|
|
let trimmed = raw.trimmingCharacters(in: .whitespaces)
|
|
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
|
|
}
|
|
|
|
// MARK: - Error View
|
|
|
|
private func errorView(_ message: String) -> some View {
|
|
ContentUnavailableView {
|
|
Label("Unable to Load", systemImage: "exclamationmark.triangle")
|
|
} description: {
|
|
Text("Unable to load flight data.\n\(message)")
|
|
}
|
|
}
|
|
|
|
// MARK: - Spirit Unavailable
|
|
|
|
private var spiritUnavailableView: some View {
|
|
ContentUnavailableView {
|
|
Label("Not Available", systemImage: "info.circle")
|
|
} description: {
|
|
Text("Spirit Airlines does not provide standby or load data.")
|
|
}
|
|
}
|
|
|
|
// MARK: - Unsupported Airline
|
|
|
|
private var unsupportedAirlineView: some View {
|
|
ContentUnavailableView {
|
|
Label("Not Available", systemImage: "info.circle")
|
|
} description: {
|
|
Text("Load data not available for \(schedule.airline.name).")
|
|
}
|
|
}
|
|
|
|
// MARK: - Load Content
|
|
|
|
private func loadContent(_ load: FlightLoad) -> some View {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 20) {
|
|
flightHeader
|
|
|
|
// MARK: - Summary Numbers (hero stats)
|
|
summaryCardUnified(load)
|
|
|
|
if !load.cabins.isEmpty {
|
|
cabinSection(load.cabins)
|
|
}
|
|
|
|
if !load.seatAvailability.isEmpty {
|
|
seatAvailabilitySection(load.seatAvailability)
|
|
}
|
|
|
|
if !load.upgradeList.isEmpty {
|
|
collapsiblePassengerSection(
|
|
title: "Upgrade Waitlist",
|
|
count: load.upgradeList.count,
|
|
passengers: load.upgradeList
|
|
)
|
|
}
|
|
|
|
if !load.standbyList.isEmpty || load.totalStandbyFromPBTS > 0 {
|
|
collapsiblePassengerSection(
|
|
title: "Standby List",
|
|
count: max(load.standbyList.count, load.totalStandbyFromPBTS),
|
|
passengers: load.standbyList
|
|
)
|
|
}
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 12)
|
|
}
|
|
}
|
|
|
|
// MARK: - Flight Header
|
|
|
|
private var flightHeader: some View {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text("\(schedule.airline.iata) \(extractFlightNumber(from: schedule.flightNumber)) \u{00B7} \(departureCode) \u{2192} \(arrivalCode)")
|
|
.font(.title3.weight(.bold))
|
|
.foregroundStyle(FlightTheme.textPrimary)
|
|
|
|
Text(schedule.airline.name)
|
|
.font(.subheadline)
|
|
.foregroundStyle(FlightTheme.textSecondary)
|
|
}
|
|
.padding(.bottom, 4)
|
|
}
|
|
|
|
// MARK: - Cabin Section
|
|
|
|
private func cabinSection(_ cabins: [CabinLoad]) -> some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
Text("CABIN AVAILABILITY")
|
|
.font(FlightTheme.label())
|
|
.foregroundStyle(FlightTheme.textTertiary)
|
|
|
|
VStack(spacing: 0) {
|
|
ForEach(Array(cabins.enumerated()), id: \.element.id) { index, cabin in
|
|
cabinRow(cabin)
|
|
|
|
if index < cabins.count - 1 {
|
|
Divider()
|
|
.padding(.horizontal, FlightTheme.cardPadding)
|
|
}
|
|
}
|
|
}
|
|
.background(FlightTheme.cardBackground)
|
|
.clipShape(RoundedRectangle(cornerRadius: FlightTheme.cardCornerRadius))
|
|
}
|
|
}
|
|
|
|
private func cabinRow(_ cabin: CabinLoad) -> some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
HStack {
|
|
Text(cabin.name)
|
|
.font(.subheadline.weight(.semibold))
|
|
.foregroundStyle(FlightTheme.textPrimary)
|
|
|
|
Spacer()
|
|
|
|
Text("\(cabin.available) available")
|
|
.font(.subheadline)
|
|
.foregroundStyle(FlightTheme.textSecondary)
|
|
}
|
|
|
|
loadProgressBar(loadFactor: cabin.loadFactor, loadColor: cabin.loadColor)
|
|
|
|
if cabin.capacity > 0 {
|
|
Text("\(cabin.capacity - cabin.available) of \(cabin.capacity) seats \u{00B7} \(Int(cabin.loadFactor * 100))%")
|
|
.font(FlightTheme.label(11))
|
|
.foregroundStyle(FlightTheme.textTertiary)
|
|
}
|
|
}
|
|
.padding(FlightTheme.cardPadding)
|
|
}
|
|
|
|
// MARK: - Summary Card (unified for all airlines)
|
|
|
|
private func summaryCardUnified(_ load: FlightLoad) -> some View {
|
|
let openSeats: Int
|
|
let standbyCount: Int
|
|
|
|
if load.hasCabinData {
|
|
// United-style: derive from pbts
|
|
openSeats = load.totalAvailable
|
|
standbyCount = load.totalStandbyFromPBTS
|
|
} else {
|
|
// AA-style: sum from seatAvailability
|
|
openSeats = load.seatAvailability.reduce(0) { $0 + $1.available }
|
|
standbyCount = load.standbyList.count
|
|
}
|
|
|
|
return HStack(spacing: 0) {
|
|
VStack(spacing: 4) {
|
|
Text("\(openSeats)")
|
|
.font(.system(size: 36, weight: .bold, design: .rounded))
|
|
.foregroundStyle(FlightTheme.onTime)
|
|
Text("Open Seats")
|
|
.font(FlightTheme.label())
|
|
.foregroundStyle(FlightTheme.textSecondary)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
|
|
Divider()
|
|
.frame(height: 50)
|
|
|
|
VStack(spacing: 4) {
|
|
Text("\(standbyCount)")
|
|
.font(.system(size: 36, weight: .bold, design: .rounded))
|
|
.foregroundStyle(standbyCount > openSeats ? FlightTheme.cancelled : FlightTheme.delayed)
|
|
Text("On Standby")
|
|
.font(FlightTheme.label())
|
|
.foregroundStyle(FlightTheme.textSecondary)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
.padding(.vertical, 16)
|
|
.flightCard()
|
|
}
|
|
|
|
// MARK: - Collapsible Passenger Section
|
|
|
|
private func collapsiblePassengerSection(title: String, count: Int, passengers: [StandbyPassenger]) -> some View {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
let isExpanded = title.contains("Upgrade") ? isUpgradeListExpanded : isStandbyListExpanded
|
|
|
|
Button {
|
|
withAnimation(.easeInOut(duration: 0.2)) {
|
|
if title.contains("Upgrade") {
|
|
isUpgradeListExpanded.toggle()
|
|
} else {
|
|
isStandbyListExpanded.toggle()
|
|
}
|
|
}
|
|
} label: {
|
|
HStack {
|
|
Text("\(title.uppercased()) (\(count))")
|
|
.font(FlightTheme.label())
|
|
.foregroundStyle(FlightTheme.textTertiary)
|
|
Spacer()
|
|
Image(systemName: isExpanded ? "chevron.up" : "chevron.down")
|
|
.font(.caption)
|
|
.foregroundStyle(FlightTheme.textTertiary)
|
|
}
|
|
}
|
|
.buttonStyle(.plain)
|
|
.padding(.bottom, isExpanded ? 12 : 0)
|
|
|
|
if isExpanded {
|
|
if passengers.isEmpty {
|
|
Text("\(count) passenger\(count == 1 ? "" : "s") on \(title.lowercased())")
|
|
.font(.subheadline)
|
|
.foregroundStyle(FlightTheme.textSecondary)
|
|
.padding(FlightTheme.cardPadding)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(FlightTheme.cardBackground)
|
|
.clipShape(RoundedRectangle(cornerRadius: FlightTheme.cardCornerRadius))
|
|
} else {
|
|
VStack(spacing: 0) {
|
|
ForEach(Array(passengers.enumerated()), id: \.element.id) { index, passenger in
|
|
passengerRow(passenger)
|
|
|
|
if index < passengers.count - 1 {
|
|
Divider()
|
|
.padding(.horizontal, FlightTheme.cardPadding)
|
|
}
|
|
}
|
|
}
|
|
.background(FlightTheme.cardBackground)
|
|
.clipShape(RoundedRectangle(cornerRadius: FlightTheme.cardCornerRadius))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Seat Availability (AA-style, no capacity data)
|
|
|
|
private func seatAvailabilitySection(_ items: [SeatAvailability]) -> some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
Text("SEAT AVAILABILITY")
|
|
.font(FlightTheme.label())
|
|
.foregroundStyle(FlightTheme.textTertiary)
|
|
|
|
VStack(spacing: 0) {
|
|
ForEach(Array(items.enumerated()), id: \.element.id) { index, item in
|
|
HStack {
|
|
Text(item.label)
|
|
.font(.subheadline.weight(.semibold))
|
|
.foregroundStyle(FlightTheme.textPrimary)
|
|
|
|
Spacer()
|
|
|
|
Text("\(item.available) available")
|
|
.font(.subheadline.weight(.medium))
|
|
.foregroundStyle(colorForAvailability(item.color))
|
|
}
|
|
.padding(FlightTheme.cardPadding)
|
|
|
|
if index < items.count - 1 {
|
|
Divider()
|
|
.padding(.horizontal, FlightTheme.cardPadding)
|
|
}
|
|
}
|
|
}
|
|
.background(FlightTheme.cardBackground)
|
|
.clipShape(RoundedRectangle(cornerRadius: FlightTheme.cardCornerRadius))
|
|
}
|
|
}
|
|
|
|
private func colorForAvailability(_ color: SeatAvailabilityColor) -> Color {
|
|
switch color {
|
|
case .success: return FlightTheme.onTime
|
|
case .warning: return FlightTheme.delayed
|
|
case .failure: return FlightTheme.cancelled
|
|
}
|
|
}
|
|
|
|
// MARK: - Progress Bar
|
|
|
|
private func loadProgressBar(loadFactor: Double, loadColor: LoadColor) -> some View {
|
|
GeometryReader { geometry in
|
|
ZStack(alignment: .leading) {
|
|
RoundedRectangle(cornerRadius: 4)
|
|
.fill(Color(.quaternarySystemFill))
|
|
|
|
RoundedRectangle(cornerRadius: 4)
|
|
.fill(colorForLoadColor(loadColor))
|
|
.frame(width: max(0, geometry.size.width * min(loadFactor, 1.0)))
|
|
}
|
|
}
|
|
.frame(height: 8)
|
|
}
|
|
|
|
private func colorForLoadColor(_ loadColor: LoadColor) -> Color {
|
|
switch loadColor {
|
|
case .green: return FlightTheme.onTime
|
|
case .yellow: return FlightTheme.delayed
|
|
case .red: return FlightTheme.cancelled
|
|
}
|
|
}
|
|
|
|
private func passengerRow(_ passenger: StandbyPassenger) -> some View {
|
|
HStack(spacing: 8) {
|
|
Text("\(passenger.order).")
|
|
.font(.subheadline.weight(.bold))
|
|
.foregroundStyle(FlightTheme.textPrimary)
|
|
.frame(width: 28, alignment: .trailing)
|
|
|
|
Text(passenger.displayName)
|
|
.font(.subheadline)
|
|
.foregroundStyle(FlightTheme.textPrimary)
|
|
|
|
Spacer()
|
|
|
|
if passenger.cleared {
|
|
HStack(spacing: 4) {
|
|
Image(systemName: "checkmark.circle.fill")
|
|
.font(.caption)
|
|
.foregroundStyle(FlightTheme.onTime)
|
|
|
|
if let seat = passenger.seat {
|
|
Text(seat)
|
|
.font(FlightTheme.label(11))
|
|
.foregroundStyle(FlightTheme.textSecondary)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.padding(.horizontal, FlightTheme.cardPadding)
|
|
.padding(.vertical, 10)
|
|
}
|
|
}
|