Files
Flights/ROUTE_EXPLORER_GUIDE.md
Trey T 4bd7a74042 Add ROUTE_EXPLORER_GUIDE.md for the new connection finder + departures features
Mirrors the existing AIRLINE_INTEGRATION_GUIDE / JSX_NOTES style: file
layout, RouteExplorerClient public API, the bridge to FlightLoadDetailView
via RouteFlight.toFlightSchedule, known limitations (rate limit, schedule-
not-loads, no test target, tz display, tenancy risk), and how-to-extend
recipes (new sort order, new airline, new upstream endpoint). Includes a
manual smoke-test walkthrough and a pointer to api_docs/ for the upstream
surface details.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 10:30:49 -05:00

14 KiB

Route Explorer Features — Implementation Guide

How "Find Connections" and "Where can I go?" are wired up in the Flights iOS app, end-to-end. Pick this up cold and you should be able to find the right file in 30 seconds.

For the upstream API surface (route-explorer.com proxy endpoints, request/response shapes, captured fixtures), see api_docs/route_explorer_api.md. This doc is the app side — how we consume it.


What these features do

Find Connections (RoutePlannerView)

Origin → destination, one date. Returns direct and multi-stop itineraries (up to 2 stops) in a single API call. Each itinerary is a complete chain of legs with valid layover times, full per-leg seat/equipment data, and a score from the upstream ranking. User flow:

  1. Tap "Find Connections" on the home screen.
  2. Pick origin (IATA picker), pick destination, pick date.
  3. Choose Max Stops (Direct / 1 / 2) and Sort By (Departure / Duration). Optional: "Interline carriers only".
  4. Tap "Search Routes" → list of ConnectionRow cards.
  5. Tap any leg in any itinerary → opens the existing FlightLoadDetailView for that flight, which calls into AirlineLoadService to pull live load if the carrier is supported.

Where can I go? (WhereToGoView)

Pick an airport, see every flight leaving in the next N hours. Useful for non-rev "what's about to leave" planning. User flow:

  1. Tap "Where can I go?" on the home screen.
  2. Pick airport.
  3. Pick window (2/4/6/12/24h) and starting time (defaults to now; "Now" button resets).
  4. Tap "Where can I go?" → list of departure cards sorted by departure time, color-coded countdown ("in 23m" red / "in 1h 15m" yellow / further out grey), capacity pills (F / J / W / Y).
  5. Tap any flight → load detail (same as Find Connections).

Cross-midnight windows are handled by firing two /departures calls (today + tomorrow) and merging.


File layout

Flights/
├── Models/
│   └── RouteExplorerModels.swift       # Codable types + bridge helpers + custom JSON decoder
├── Services/
│   └── RouteExplorerClient.swift       # actor: token cache + flight-search proxy
└── Views/
    ├── RoutePlannerView.swift          # feature (a)
    ├── WhereToGoView.swift             # feature (b)
    └── Components/
        ├── IATAAirportPicker.swift     # local-only IATA autocomplete
        └── ConnectionRow.swift         # itinerary card with chained legs

Wired in:

  • FlightsApp.swift — instantiates RouteExplorerClient once, passes through.
  • ContentView.swift — adds SearchRoute.routePlanner / .whereToGo cases plus two entry-point cards above the favorites list.

RouteExplorerClient

Actor (so concurrent searches share the token cache safely). Two public methods:

func searchRoutes(
    from origin: String,        // IATA, e.g. "DFW"
    to destination: String,     // IATA, e.g. "MCO"
    date: Date,
    maxStops: Int = 1,          // 0 = direct only, 1 = 1-stop, 2 = 2-stop
    includeInterline: Bool = false,
    sortBy: RouteSortOption = .departureTime,
    limit: Int = 100
) async throws -> RouteSearchResult

func searchDepartures(
    from origin: String,
    date: Date,
    maxStops: Int = 0,
    limit: Int = 200
) async throws -> RouteSearchResult

Returns RouteSearchResult { connections: [RouteConnection], appendix: RouteAppendix? }. Each connection has flights: [RouteFlight] (one for direct, more for multi-stop), plus durationMinutes, score, totalLayoverMinutes, carrierIatas.

Auth flow

Anonymous HMAC token from https://route-explorer.com/api/token, IP-rate-limited at 10/min. Cached for 30 minutes (defensive — actual TTL not measured). On HTTP 401/403 from the search endpoint, the cache is dropped and the call retries once.

Browser-shaped headers (important)

/api/token and /api/flight-search are gated by Origin / Referer checks in production. We send these on every request:

User-Agent: Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 ...)
Accept-Language: en-US,en;q=0.9
Referer: https://route-explorer.com/
Origin: https://route-explorer.com

Without them iOS gets 403. Same trick is used for United in AirlineLoadService.applyUnitedBrowserHeaders.

Error surface

RouteExplorerClient.ClientError {
    case tokenFetchFailed(status: Int)
    case requestFailed(status: Int, body: String?)
    case decodingFailed(underlying: Error)
}

All cases conform to LocalizedError. The views show errorDescription directly in a ContentUnavailableView.

Date handling

Upstream returns ISO-8601 timestamps with offsets (e.g. "2026-04-28T16:45:00-05:00"). We use a custom decoder factory JSONDecoder.routeExplorer() that handles both withFractionalSeconds and plain withInternetDateTime. Date params (startDate, endDate, departureDates) go up as "yyyy-MM-dd" formatted in UTC for stability.


The bridge to FlightLoadDetailView

Tap-through is the key UX coupling. RouteFlight.toFlightSchedule(appendix:on:) synthesizes a FlightSchedule from a route-explorer leg so the existing detail view doesn't need to know anything about route-explorer.

// In a tap handler:
selectedDepCode = leg.departure.airportIata
selectedArrCode = leg.arrival.airportIata
selectedDate = leg.departure.dateTime
selectedFlight = leg.toFlightSchedule(appendix: appendix, on: leg.departure.dateTime)

Then .sheet(item: $selectedFlight) { ... FlightLoadDetailView(...) } and the existing flow runs unchanged. FlightLoadDetailView calls AirlineLoadService.fetchLoad(airlineCode:flightNumber:date:origin:destination:departureTime:), which:

  • Routes to a per-airline fetcher for UA, AA, NK, KE, B6, AS, EK, XE
  • Returns nil for unsupported carriers → the detail view shows "Load data not available for {airline name}" (intentional graceful degrade — the user still sees the schedule/equipment/seat capacity from route-explorer)

The appendix from /route and /departures (we always pass includeAppendix: true) supplies the airline display name and the equipment friendly name. Without it the detail view falls back to IATA codes.

Logo limitation

Synthesized Airline.logoFilename = "\(iata.lowercased()).png". Most route-explorer carriers won't have a matching file in the FlightConnections CDN, so AsyncImage drops to its placeholder (colored circle with first letter). This is fine but noticeable — the regular FlightConnections-sourced search shows real logos. Two ways to upgrade if it bothers you:

  1. Pull logos from images.stafftraveler.com (in the route-explorer CSP allowlist) — would need a separate filename convention.
  2. Bundle a static Resources/airlines.json mapping IATA → FC logo filename, look it up at synthesis time.

RoutePlannerView (Find Connections)

Renders three configuration sections (airports / date / stops+sort+interline), a search button, then a list of ConnectionRow cards. State:

@State origin: MapAirport?
@State destination: MapAirport?
@State date: Date
@State maxStops: Int = 1
@State sortBy: RouteSortOption = .departureTime
@State includeInterline: Bool = false

@State isLoading: Bool
@State error: String?
@State connections: [RouteConnection]
@State appendix: RouteAppendix?

Search button is disabled until both airports are picked. On submit, calls client.searchRoutes(...) and stores connections + appendix. Empty results render a "No routes found from X to Y" ContentUnavailableView.

ConnectionRow

One card per itinerary, top-level shows:

  • Stops badge ("Direct" / "1 stop" / "2 stops")
  • Carrier names (single carrier shown by full name, multi-carrier as IATA list)
  • Total duration ("4h 23m")

Then per-leg LegSummary rows separated by layover indicators ("Layover at SEA · 1h 12m"). Each leg is its own Button so the per-leg tap doesn't conflict with any future expand-row UX.


WhereToGoView (Where can I go?)

Renders an airport picker, window selector, time anchor (date+time picker with "Now" button), search button, then departure cards. State:

@State origin: MapAirport?
@State windowHours: Int = 6
@State referenceDate: Date    // "starting from"

@State connections: [RouteConnection]   // each is one single leg
@State appendix: RouteAppendix?

/departures with maxStops: 0 returns one connection per single-leg flight. We flatten and filter:

private var filteredFlights: [RouteFlight] {
    let windowEnd = referenceDate.addingTimeInterval(TimeInterval(windowHours * 3600))
    return connections.flatMap { $0.flights }
        .filter { $0.departure.dateTime >= referenceDate
               && $0.departure.dateTime <= windowEnd }
        .sorted { $0.departure.dateTime < $1.departure.dateTime }
}

Cross-midnight: if referenceDate and windowEnd are different calendar days, fire a second /departures for the next day and merge.

DepartureLegRow

Per-flight card with:

  • Carrier IATA + flight number, airline name
  • Local departure time + relative countdown ("in 23m") with traffic-light color
  • IATA codes as big airport-signage text + tiny airplane icon between
  • Aircraft pill
  • Capacity pills (F·12, J·20, W·0, Y·176) — only shown for cabins with seats

Known limitations / risks

  1. Tenancy on route-explorer.com. The whole feature relies on StaffTraveler's free public proxy. They could flip a referer/origin check we don't satisfy, rotate the HMAC scheme, or just rate-limit harder at any time. No SLA. For load-bearing personal use this is fine; for shipped users you'd want a fallback or a paid api.stafftraveler.com integration.

  2. Rate limit (10/min per IP). Hitting search rapidly (e.g. user retries) can get you 429-ish. The token cache softens this since /api/token is the rate-limited bit; subsequent /api/flight-search calls reuse the cached token.

  3. Schedule, not loads. Capacity pills show seat layout (e.g. 196 total, 20 J / 176 Y), not available seats. Live load requires the per-airline fetchers in AirlineLoadService and is only available for the 8 supported carriers. For unsupported carriers, the detail view degrades to schedule-only.

  4. No test target in the project. None of this code has unit tests. The pieces worth testing (date decoder, layover math, toFlightSchedule bridge, RouteCabins.asCabinClass) all have JSON fixtures saved at api_docs/route_explorer_captures/*.json ready for use.

  5. Time-zone display. Leg times are formatted using device local time, not the airport's local time. For most users browsing their own region this looks correct; for cross-timezone planning the times will display in the user's zone. Fix would require an IATA → IANA timezone map.

  6. Single date per call. departureDates accepts an array but we only ever pass one date. For "any of next 3 days" you'd fire N parallel calls and merge in the view layer (or extend the client API to accept multiple dates).


Adding a new feature to this surface

A new sort order

Add a case to RouteSortOption (in RouteExplorerModels.swift):

enum RouteSortOption: String, CaseIterable, Sendable {
    case departureTime = "departure_time"
    case duration = "duration"
    case score = "score"            // ← upstream supports this; not yet exposed
    ...
    var label: String { ... }       // user-facing label
}

The picker in RoutePlannerView iterates allCases so it auto-shows up.

A new airline supported by load lookup

Add a case to the switch code block in AirlineLoadService.fetchLoad(...). Because both new views funnel through FlightLoadDetailView, a new airline immediately becomes tap-functional in both. Document the wire details in AIRLINE_INTEGRATION_GUIDE.md.

A new upstream endpoint

The upstream proxy allowlist is /route, /route-batch, /flight, /departures, /schedule. To call e.g. /schedule:

func searchSchedule(carrier: String, flightNumber: Int, start: Date, end: Date) async throws -> RouteSearchResult {
    let payload: [String: Any] = [
        "carrierCode": carrier,
        "flightNumber": flightNumber,
        "startDate": dateFormatter.string(from: start),
        "endDate": dateFormatter.string(from: end),
        "limit": 1000,
        "includeAppendix": true
    ]
    return try await callFlightSearch(endpoint: "/schedule", json: payload)
}

The decoder already handles the response shape (same connections[] envelope across all endpoints).

A real-airport timezone display

Bundle a IATA→IANA map (e.g. from the airports.json already in the bundle if it has TZ data — check first). Update RouteFlight.toFlightSchedule and DepartureLegRow.timeFormatter to use a TimeZone(identifier: ...) keyed off the airport's IATA.


Quick smoke test (manual)

  1. Run app in simulator.
  2. Home → "Find Connections" → DFW → KOA → date a few days out → 1 stop → Search. Expect: a few DFW→SEA→KOA (Alaska) and DFW→LAX→KOA (American) itineraries, with valid layovers visible between legs.
  3. Tap any leg. Expect: load detail view loads. For AA / AS legs, you'll see real load data; for others you'll see schedule + the "Load data not available" state.
  4. Back. Home → "Where can I go?" → DFW → 6h → Now → Search. Expect: dozens of departures sorted by time, with red/yellow/grey countdowns and capacity pills.
  5. Open Xcode console. On any failure look for [RouteExplorer] /api/token failed status=... — that's the diagnostic line we added; the body usually explains what the proxy didn't like.

Pointer to upstream documentation

api_docs/route_explorer_api.md — full endpoint reference including:

  • The {json:{...}} SuperJSON envelope
  • Required fields per endpoint (from server-side Zod validation errors)
  • Real captured request/response samples in api_docs/route_explorer_captures/
  • Notes on /route-batch (not used; not fully reverse-engineered)

The captures (route_DFW_KOA_1stop.json etc.) double as test fixtures whenever the test target gets added.