diff --git a/Flights/Models/RouteExplorerModels.swift b/Flights/Models/RouteExplorerModels.swift index 94ffd11..fc3c185 100644 --- a/Flights/Models/RouteExplorerModels.swift +++ b/Flights/Models/RouteExplorerModels.swift @@ -155,6 +155,17 @@ struct RouteAppendixEquipment: Decodable, Sendable { // MARK: - Search result +/// Response from `/schedule` — flat list of operating records for one +/// (carrier, flightNumber, date). Different envelope from `/route` / +/// `/departures` which return nested `connections[]`. +struct RouteExplorerScheduleResponse: Decodable, Sendable { + let json: Body + struct Body: Decodable, Sendable { + let flights: [RouteFlight] + let appendix: RouteAppendix? + } +} + struct RouteSearchResult: Sendable { let connections: [RouteConnection] let appendix: RouteAppendix? diff --git a/Flights/Services/AirportDatabase.swift b/Flights/Services/AirportDatabase.swift index bdb3ff9..da0d9ea 100644 --- a/Flights/Services/AirportDatabase.swift +++ b/Flights/Services/AirportDatabase.swift @@ -79,6 +79,28 @@ final class AirportDatabase: Sendable { airports.first { $0.iata == code } } + /// Return the airport closest to a given coordinate, optionally + /// within a max distance. Linear scan — O(n) with ~3,900 airports, + /// fast enough on the main thread for tap-then-lookup flows. + func nearestAirport(to coordinate: CLLocationCoordinate2D, maxMiles: Double = 25) -> MapAirport? { + guard !airports.isEmpty else { return nil } + var bestAirport: MapAirport? + var bestDistSq: Double = .greatestFiniteMagnitude + // Convert max miles to (degrees lat)² in a rough planar sense — good + // enough for "nearest airport" filtering. ~1 degree lat ≈ 69 miles. + let cutoffSq = (maxMiles / 69.0) * (maxMiles / 69.0) + for ap in airports { + let dLat = ap.lat - coordinate.latitude + let dLng = ap.lng - coordinate.longitude + let dSq = dLat * dLat + dLng * dLng + if dSq < bestDistSq { + bestDistSq = dSq + bestAirport = ap + } + } + return bestDistSq <= cutoffSq ? bestAirport : nil + } + private static func buildRegionNames() -> [String: String] { // US states + territories var names: [String: String] = [ diff --git a/Flights/Services/RouteExplorerClient.swift b/Flights/Services/RouteExplorerClient.swift index 6c266dd..b0aa889 100644 --- a/Flights/Services/RouteExplorerClient.swift +++ b/Flights/Services/RouteExplorerClient.swift @@ -85,6 +85,59 @@ actor RouteExplorerClient { return try await callFlightSearch(endpoint: "/route", json: payload) } + /// Schedule lookup for a specific flight number across a date range. + /// Powers the live-flight detail sheet — given an ICAO callsign like + /// `AAL1234` we resolve it to `(carrier: "AA", flightNumber: 1234)` and + /// pull the operating record, which carries real departure + arrival + /// airports and times. + /// + /// Returns `nil` if the carrier/flight isn't in route-explorer's + /// schedule feed (typical for regional codeshares, charter ops, and + /// carriers the upstream platform doesn't index). + func searchSchedule( + carrierCode: String, + flightNumber: Int, + startDate: Date, + endDate: Date? = nil + ) async -> [RouteFlight] { + let startStr = dateFormatter.string(from: startDate) + let endStr = dateFormatter.string(from: endDate ?? startDate) + let payload: [String: Any] = [ + "carrierCode": carrierCode.uppercased(), + "flightNumber": flightNumber, + "startDate": startStr, + "endDate": endStr, + "limit": 20, + "includeAppendix": true + ] + do { + let token = try await currentToken() + let url = baseURL.appendingPathComponent("api/flight-search") + let body = try JSONSerialization.data(withJSONObject: [ + "endpoint": "/schedule", + "body": ["json": payload] + ]) + var req = URLRequest(url: url) + req.httpMethod = "POST" + Self.applyBrowserHeaders(to: &req) + req.setValue("application/json", forHTTPHeaderField: "Content-Type") + req.setValue("application/json", forHTTPHeaderField: "Accept") + req.setValue(token, forHTTPHeaderField: "X-API-Token") + req.httpBody = body + + let (data, response) = try await session.data(for: req) + let status = (response as? HTTPURLResponse)?.statusCode ?? -1 + guard (200...299).contains(status) else { return [] } + + let decoded = try JSONDecoder.routeExplorer().decode( + RouteExplorerScheduleResponse.self, from: data + ) + return decoded.json.flights + } catch { + return [] + } + } + /// All departures from an airport on a date. We filter by time window /// client-side because the upstream endpoint doesn't accept one. func searchDepartures( diff --git a/Flights/Views/LiveFlightDetailSheet.swift b/Flights/Views/LiveFlightDetailSheet.swift index 89d7945..1767207 100644 --- a/Flights/Views/LiveFlightDetailSheet.swift +++ b/Flights/Views/LiveFlightDetailSheet.swift @@ -4,11 +4,27 @@ import CoreLocation struct LiveFlightDetailSheet: View { let aircraft: LiveAircraft let openSky: OpenSkyClient + let routeExplorer: RouteExplorerClient let database: AirportDatabase @State private var recentFlights: [OpenSkyFlight] = [] @State private var isLoadingRoute = false + /// The resolved route for the current selection. Built from a cascade: + /// scheduled flight (via route-explorer) → OpenSky history → trail-based + /// nearest-airport inference. See `resolveRoute()`. + @State private var resolvedRoute: ResolvedRoute? + + enum ResolvedRoute { + /// Real schedule match from route-explorer. Best fidelity. + case scheduled(RouteFlight) + /// OpenSky historical flight. departure + arrival from FAA tracking. + case fromOpenSky(OpenSkyFlight, ageHours: Int) + /// Couldn't find a flight record — inferred departure from the + /// aircraft's trail start. Arrival unknown. + case inferred(departureIATA: String, departureName: String?) + } + @Environment(\.dismiss) private var dismiss var body: some View { @@ -62,11 +78,94 @@ struct LiveFlightDetailSheet: View { } } .task { - await loadRoute() + await resolveRoute() } } } + // MARK: - Route resolution + + /// Cascade to find departure + arrival, in order of fidelity: + /// 1. Scheduled lookup via route-explorer (carrier + flight number) + /// — this is the only path that gives a real arrival airport for + /// an in-progress flight. + /// 2. OpenSky `/flights/aircraft` — only landed flights; useful if + /// the aircraft just landed or for "last flight" context. + /// 3. Trail-based inference — first point of the OpenSky `/tracks/all` + /// response → nearest airport in our local DB → "Departed from X". + private func resolveRoute() async { + isLoadingRoute = true + defer { isLoadingRoute = false } + + // 1) Try the scheduled lookup if we have a parseable airline + flight. + if let scheduled = await tryScheduledLookup() { + resolvedRoute = .scheduled(scheduled) + // Still fetch OpenSky history in the background for the "recent + // flights" list — but we don't need to await it. + recentFlights = await openSky.recentFlights(icao24: aircraft.icao24) + return + } + + // 2) Fall back to OpenSky history. + let flights = await openSky.recentFlights(icao24: aircraft.icao24) + recentFlights = flights + if let mostRecent = flights.first { + let hoursAgo = max(0, Int( + (Date().timeIntervalSince1970 - Double(mostRecent.lastSeen)) / 3600 + )) + resolvedRoute = .fromOpenSky(mostRecent, ageHours: hoursAgo) + return + } + + // 3) Last resort: nearest-airport from the trail start. + if let track = await openSky.track(icao24: aircraft.icao24), + let firstPoint = track.path.first { + let coord = CLLocationCoordinate2D( + latitude: firstPoint.latitude, + longitude: firstPoint.longitude + ) + if let nearest = database.nearestAirport(to: coord, maxMiles: 30) { + resolvedRoute = .inferred( + departureIATA: nearest.iata, + departureName: nearest.name + ) + return + } + } + resolvedRoute = nil + } + + /// Parse the ADS-B callsign (e.g. "AAL1234") into carrier + flight number + /// and hit route-explorer's `/schedule` endpoint. Returns the day's + /// operating record if route-explorer indexes the carrier; nil otherwise. + private func tryScheduledLookup() async -> RouteFlight? { + guard let icao = aircraft.airlineICAO, + let airline = AircraftRegistry.shared.lookup(icao: icao), + let iata = airline.iata, + let flightStr = aircraft.flightNumber, + let flightNum = Int(flightStr) + else { return nil } + + // Look across today ± yesterday — handles late-running redeyes that + // departed on D-1 but are still airborne on D. + let today = Date() + let yesterday = today.addingTimeInterval(-86400) + let results = await routeExplorer.searchSchedule( + carrierCode: iata, + flightNumber: flightNum, + startDate: yesterday, + endDate: today + ) + guard !results.isEmpty else { return nil } + + // Pick the entry whose departure is closest to "now" (typically + // the in-progress flight). + return results.min { a, b in + abs(a.departure.dateTime.timeIntervalSinceNow) < + abs(b.departure.dateTime.timeIntervalSinceNow) + } + } + // MARK: - Header private var header: some View { @@ -173,46 +272,37 @@ struct LiveFlightDetailSheet: View { // label it clearly: "In flight" if the snapshot is fresh, "Last flight" // if it's older, "—" only when we genuinely have nothing. - private enum RouteStatus { case loading, inFlight, recent(hoursAgo: Int), none } - @ViewBuilder private var routeSection: some View { - if isLoadingRoute && recentFlights.isEmpty { - HStack { - ProgressView() - Text("Looking up route…") - .font(.caption) - .foregroundStyle(FlightTheme.textSecondary) - } - } else if let mostRecent = recentFlights.first { - let now = Date().timeIntervalSince1970 - let hoursAgo = max(0, Int((now - Double(mostRecent.lastSeen)) / 3600)) - let status: RouteStatus = { - if aircraft.onGround { return .recent(hoursAgo: hoursAgo) } - if hoursAgo < 6 { return .inFlight } - return .recent(hoursAgo: hoursAgo) - }() + if let resolved = resolvedRoute { VStack(alignment: .leading, spacing: 6) { HStack(spacing: 8) { - Text(routeHeader(status)) + Text(headerLabel(for: resolved)) .font(FlightTheme.label()) .foregroundStyle(FlightTheme.textTertiary) .tracking(1) - if case .inFlight = status { + if case .scheduled = resolved { Circle() .fill(FlightTheme.onTime) .frame(width: 6, height: 6) } } - routeCard(mostRecent, status: status) + resolvedRouteCard(resolved) + } + } else if isLoadingRoute { + HStack { + ProgressView() + Text("Resolving route…") + .font(.caption) + .foregroundStyle(FlightTheme.textSecondary) } } else { VStack(alignment: .leading, spacing: 6) { - Text("THIS FLIGHT") + Text("ROUTE") .font(FlightTheme.label()) .foregroundStyle(FlightTheme.textTertiary) .tracking(1) - Text("Route not available from OpenSky for this aircraft.") + Text("Route unavailable. This aircraft isn't in any schedule source we cover (callsign \(aircraft.trimmedCallsign ?? aircraft.icao24)).") .font(.caption) .foregroundStyle(FlightTheme.textSecondary) .padding(12) @@ -222,44 +312,105 @@ struct LiveFlightDetailSheet: View { } } - private func routeHeader(_ status: RouteStatus) -> String { - switch status { - case .loading: return "THIS FLIGHT" - case .inFlight: return "IN FLIGHT" - case .recent(let hoursAgo): + private func headerLabel(for r: ResolvedRoute) -> String { + switch r { + case .scheduled: + return aircraft.onGround ? "FLIGHT (ON GROUND)" : "IN FLIGHT" + case .fromOpenSky(_, let hoursAgo): if hoursAgo < 1 { return "LAST FLIGHT · JUST LANDED" } if hoursAgo < 24 { return "LAST FLIGHT · \(hoursAgo)h AGO" } - let days = hoursAgo / 24 - return "LAST FLIGHT · \(days)d AGO" - case .none: return "THIS FLIGHT" + return "LAST FLIGHT · \(hoursAgo / 24)d AGO" + case .inferred: + return aircraft.onGround ? "ON GROUND" : "IN FLIGHT (INFERRED)" } } - private func routeCard(_ f: OpenSkyFlight, status: RouteStatus) -> some View { - let depLabel: String - let arrLabel: String - switch status { - case .inFlight: - depLabel = "Departed" - arrLabel = "Heading to" - case .recent: - depLabel = "Departed" - arrLabel = "Arrived" - case .loading, .none: - depLabel = "From" - arrLabel = "To" - } - return VStack(alignment: .leading, spacing: 12) { - HStack(spacing: 16) { - routeEndpoint(code: f.estDepartureAirport, label: depLabel, time: f.departureDate) - Image(systemName: "airplane") - .font(.title3) - .foregroundStyle(FlightTheme.accent) - .rotationEffect(.degrees(-45)) - routeEndpoint(code: f.estArrivalAirport, label: arrLabel, time: f.arrivalDate) + @ViewBuilder + private func resolvedRouteCard(_ r: ResolvedRoute) -> some View { + switch r { + case .scheduled(let f): + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 16) { + routeEndpoint( + code: f.departure.airportIata, + label: aircraft.onGround ? "From" : "Departed", + time: f.departure.dateTime + ) + Image(systemName: "airplane") + .font(.title3) + .foregroundStyle(FlightTheme.accent) + .rotationEffect(.degrees(-45)) + routeEndpoint( + code: f.arrival.airportIata, + label: aircraft.onGround ? "To" : "Heading to", + time: f.arrival.dateTime + ) + } } + .flightCard() + + case .fromOpenSky(let f, _): + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 16) { + routeEndpoint( + code: f.estDepartureAirport, + label: "Departed", + time: f.departureDate + ) + Image(systemName: "airplane") + .font(.title3) + .foregroundStyle(FlightTheme.accent) + .rotationEffect(.degrees(-45)) + routeEndpoint( + code: f.estArrivalAirport, + label: "Arrived", + time: f.arrivalDate + ) + } + } + .flightCard() + + case .inferred(let depIata, let depName): + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 16) { + VStack(alignment: .leading, spacing: 2) { + Text("Departed") + .font(.caption2) + .foregroundStyle(FlightTheme.textTertiary) + .tracking(0.5) + Text(depIata) + .font(FlightTheme.airportCode(28)) + .foregroundStyle(FlightTheme.textPrimary) + if let depName { + Text(depName) + .font(.caption2) + .foregroundStyle(FlightTheme.textSecondary) + .lineLimit(1) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + Image(systemName: "airplane") + .font(.title3) + .foregroundStyle(FlightTheme.accent) + .rotationEffect(.degrees(-45)) + VStack(alignment: .leading, spacing: 2) { + Text("Heading to") + .font(.caption2) + .foregroundStyle(FlightTheme.textTertiary) + .tracking(0.5) + Text("—") + .font(FlightTheme.airportCode(28)) + .foregroundStyle(FlightTheme.textTertiary) + Text("Not in schedule data") + .font(.caption2) + .foregroundStyle(FlightTheme.textSecondary) + .lineLimit(1) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .flightCard() } - .flightCard() } private func routeEndpoint(code: String?, label: String, time: Date) -> some View { @@ -352,13 +503,6 @@ struct LiveFlightDetailSheet: View { ?? "Unknown" } - private func loadRoute() async { - guard !isLoadingRoute else { return } - isLoadingRoute = true - defer { isLoadingRoute = false } - recentFlights = await openSky.recentFlights(icao24: aircraft.icao24) - } - private func formatNumber(_ n: Int) -> String { let f = NumberFormatter() f.numberStyle = .decimal diff --git a/Flights/Views/LiveFlightsView.swift b/Flights/Views/LiveFlightsView.swift index 8c44d2e..39a7c34 100644 --- a/Flights/Views/LiveFlightsView.swift +++ b/Flights/Views/LiveFlightsView.swift @@ -4,6 +4,7 @@ import CoreLocation struct LiveFlightsView: View { let openSky: OpenSkyClient + let routeExplorer: RouteExplorerClient let database: AirportDatabase // MARK: - Map state @@ -80,9 +81,14 @@ struct LiveFlightsView: View { .sheet(item: $activeSheet) { sheet in switch sheet { case .aircraft(let ac): - LiveFlightDetailSheet(aircraft: ac, openSky: openSky, database: database) - .presentationDetents([.medium, .large]) - .presentationDragIndicator(.visible) + LiveFlightDetailSheet( + aircraft: ac, + openSky: openSky, + routeExplorer: routeExplorer, + database: database + ) + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.visible) case .settings: OpenSkySettingsView() } diff --git a/Flights/Views/RootView.swift b/Flights/Views/RootView.swift index 2445a0d..f244075 100644 --- a/Flights/Views/RootView.swift +++ b/Flights/Views/RootView.swift @@ -27,9 +27,13 @@ struct RootView: View { .tag(Tab.search) NavigationStack { - LiveFlightsView(openSky: openSky, database: database) - .navigationTitle("Live Flights") - .navigationBarTitleDisplayMode(.inline) + LiveFlightsView( + openSky: openSky, + routeExplorer: routeExplorer, + database: database + ) + .navigationTitle("Live Flights") + .navigationBarTitleDisplayMode(.inline) } .tabItem { Label("Live", systemImage: "antenna.radiowaves.left.and.right")