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>
This commit is contained in:
@@ -0,0 +1,292 @@
|
|||||||
|
# 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:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
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
|
||||||
|
|
||||||
|
```swift
|
||||||
|
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.
|
||||||
|
|
||||||
|
```swift
|
||||||
|
// 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:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
@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:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
@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:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
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`):
|
||||||
|
```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`:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
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.
|
||||||
Reference in New Issue
Block a user