Files
Flights/Flights/Views/FlightLoadDetailView.swift
Trey t 4d46b836a1 JSX: per-flight loads via in-page fetch POST (real device only)
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>
2026-04-11 13:44:30 -05:00

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