Files
Flights/Flights/Views/LiveFlightDetailSheet.swift
T
Trey T 803c812f86 History v2: everything — Wallet auto-prompt, age, track replay, share
Adds the deferred pieces from the v1 ship, plus a Mail Share
Extension target so the iOS share sheet picks up flight emails.

Track replay
- `LoggedFlight.icao24` field — populated from FR24 enrichment on
  live-tap adds.
- HistoryDetailView's track query now fires for any flight younger
  than 7 days that has an icao24, pulling the actual flown path
  from OpenSky's /tracks/all endpoint. Falls back to a clean
  great-circle arc otherwise.

Wallet auto-prompt
- RootView subscribes to WalletPassObserver.shared. When the user
  adds a boarding pass to Apple Wallet, the observer's published
  `pendingPass` flips and we present AddFlightView pre-filled with
  the parsed origin / destination / flight # / date.

Airframe age + first-flight date
- `AirframeMetadataService` queries OpenSky's
  /api/metadata/aircraft/icao/{icao24} endpoint. Caches results in
  the existing `AirframeMetadata` SwiftData model so we never
  re-fetch the same airframe twice. (jetphotos and planespotters
  pages are both Cloudflare-gated; OpenSky's metadata API is the
  cleanest free source.)
- HistoryDetailView fires the lookup on appear and persists the
  result; the aircraft card already renders "Age" when a date is
  cached.

Mail Share Extension
- New `FlightsShareExtension` Xcode target (app-extension product
  type) built into the app bundle via an Embed Foundation
  Extensions copy phase.
- `ShareViewController` (SLComposeServiceViewController) parses
  shared text + URLs for flight-shaped codes ("AA 2178"), route
  hints ("DFW → ORD"), and date strings.
- On Save, the extension builds a `flights://import?carrier=…&num=
  …&dep=…&arr=…&date=…` URL and opens it via the responder-chain
  openURL trick (Share Extensions can't access UIApplication
  directly).
- Host app handles the URL via `.onOpenURL` in RootView, switches
  to the History tab and presents AddFlightView prefilled.
- App now has an actual Info.plist (CFBundleURLTypes registered
  for `flights://`); switched from GENERATE_INFOPLIST_FILE to
  INFOPLIST_FILE for the app target.

If the dev portal hasn't registered bundle id
`com.flights.app.share` for the team, the signed archive will
fail. In that case the simpler URL-scheme path still works —
users can hit `flights://import?...` from a Shortcut.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 09:51:30 -05:00

739 lines
29 KiB
Swift

import SwiftUI
import SwiftData
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
@State private var aircraftPhoto: AircraftPhotoService.Photo?
@State private var showingAddToHistory = 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 {
/// Inline data straight off the FR24 feed (departure + arrival
/// IATA, plus the flight number). This is the fast path no
/// extra network call required because FR24 already gave us
/// these in the feed.js response.
case fromFR24(departureIATA: String?, arrivalIATA: String?, flightIATA: String?)
/// Real schedule match from route-explorer. Best fidelity for
/// flights that aren't FR24-sourced.
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
@Environment(\.openURL) private var openURL
@Environment(\.modelContext) private var modelContext
var body: some View {
NavigationStack {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
header
// Depart Arrival sits directly under the callsign
// header it's the single most important thing the
// user opened the sheet to see.
routeSection
addToHistoryButton
// Aircraft photo follows the route. Negative
// horizontal padding lets the photo break out of the
// 16pt content padding to be full-bleed edge-to-edge.
photoBanner
.padding(.horizontal, -16)
if let photo = aircraftPhoto, let credit = photo.photographer {
photoCredit(name: credit, link: photo.detailLink)
}
Divider()
Text("LIVE STATE")
.font(FlightTheme.label())
.foregroundStyle(FlightTheme.textTertiary)
.tracking(1)
liveStateGrid
if recentFlights.count > 1 {
Text("RECENT FLIGHTS")
.font(FlightTheme.label())
.foregroundStyle(FlightTheme.textTertiary)
.tracking(1)
.padding(.top, 4)
ForEach(recentFlights.prefix(8), id: \.self) { flight in
recentFlightRow(flight)
}
}
Text("AIRCRAFT")
.font(FlightTheme.label())
.foregroundStyle(FlightTheme.textTertiary)
.tracking(1)
.padding(.top, 4)
aircraftCard
}
.padding(16)
}
.background(FlightTheme.background.ignoresSafeArea())
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button { dismiss() } label: {
Image(systemName: "xmark")
.font(.subheadline.weight(.semibold))
}
}
}
.task {
await resolveRoute()
}
.task(id: aircraft.icao24) {
aircraftPhoto = await AircraftPhotoService.shared.photo(
registration: aircraft.enrichment?.registration,
icao24: aircraft.icao24
)
}
}
}
// 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 }
// 0) Fast path: FR24's feed gave us departure + arrival inline.
// This is the common case now that FR24 is the primary feed.
// Skips the route-explorer network call entirely.
if let e = aircraft.enrichment,
e.departureIATA != nil || e.arrivalIATA != nil {
resolvedRoute = .fromFR24(
departureIATA: e.departureIATA,
arrivalIATA: e.arrivalIATA,
flightIATA: e.flightIATA
)
// Fire OpenSky history in the background for "recent flights".
recentFlights = await openSky.recentFlights(icao24: aircraft.icao24)
return
}
// 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: - Add-to-history button
//
// Lives between the route card and the photo banner the natural
// "I'm on this plane right now, save it" spot. Opens AddFlightView
// pre-populated from FR24 enrichment so the user can confirm any
// detail before it lands in their log.
private var addToHistoryButton: some View {
Button {
showingAddToHistory = true
} label: {
HStack(spacing: 8) {
Image(systemName: "plus.circle.fill")
Text("Add to my flights")
.font(.subheadline.weight(.semibold))
}
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
}
.background(FlightTheme.accent, in: RoundedRectangle(cornerRadius: 12))
.foregroundStyle(.white)
.sheet(isPresented: $showingAddToHistory) {
let store = FlightHistoryStore(context: modelContext, airportDatabase: database)
AddFlightView(
routeExplorer: routeExplorer,
database: database,
store: store,
prefill: AddFlightView.Prefill(
flightDate: Date(),
carrierICAO: aircraft.airlineICAO,
carrierIATA: AircraftRegistry.shared.lookup(icao: aircraft.airlineICAO)?.iata,
flightNumber: aircraft.flightNumber,
departureIATA: aircraft.enrichment?.departureIATA,
arrivalIATA: aircraft.enrichment?.arrivalIATA,
scheduledDeparture: nil,
scheduledArrival: nil,
aircraftType: aircraft.typeCode,
registration: aircraft.enrichment?.registration,
icao24: aircraft.icao24,
source: "live-tap"
)
)
}
}
// MARK: - Photo banner
//
// Hero image at the very top of the sheet, sourced from planespotters.
// Hides itself entirely when no photo is available (lots of GA / cargo
// / older airframes have no public photo). Most recent photo per
// airframe is what planespotters serves which means special
// liveries surface naturally because photographers chase them first.
@ViewBuilder
private var photoBanner: some View {
if let photo = aircraftPhoto {
AsyncImage(url: photo.largeURL) { phase in
switch phase {
case .success(let img):
img.resizable().aspectRatio(contentMode: .fill)
case .empty, .failure:
Rectangle().fill(FlightTheme.cardBackground)
@unknown default:
Rectangle().fill(FlightTheme.cardBackground)
}
}
.frame(maxWidth: .infinity)
.frame(height: 200)
.clipped()
}
}
private func photoCredit(name: String, link: URL?) -> some View {
HStack(spacing: 4) {
Image(systemName: "camera.fill")
.font(.caption2)
Text("Photo by \(name) · planespotters.net")
.font(.caption2)
}
.foregroundStyle(FlightTheme.textTertiary)
.padding(.top, 4)
.contentShape(Rectangle())
.onTapGesture {
if let link { openURL(link) }
}
}
// MARK: - Header
private var header: some View {
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 10) {
if let logoURL = airlineEntry?.logoURL {
AsyncImage(url: logoURL) { phase in
switch phase {
case .success(let img):
img.resizable().scaledToFit()
default:
RoundedRectangle(cornerRadius: 8)
.fill(FlightTheme.accent.opacity(0.2))
}
}
.frame(width: 40, height: 40)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
Text(aircraft.trimmedCallsign ?? aircraft.icao24)
.font(.title.weight(.bold).monospaced())
.foregroundStyle(FlightTheme.textPrimary)
Spacer()
statusBadge
}
Text(airlineDisplayName)
.font(.subheadline)
.foregroundStyle(FlightTheme.textSecondary)
}
}
private var statusBadge: some View {
let (text, color): (String, Color) = {
if aircraft.onGround { return ("On ground", FlightTheme.textSecondary) }
switch aircraft.verticalState {
case .climbing: return ("Climbing", FlightTheme.onTime)
case .descending: return ("Descending", FlightTheme.delayed)
case .level: return ("Cruising", FlightTheme.accent)
}
}()
return Text(text)
.font(.caption.weight(.bold))
.foregroundStyle(.white)
.padding(.horizontal, 10)
.padding(.vertical, 4)
.background(color, in: Capsule())
}
// MARK: - Live state grid
private var liveStateGrid: some View {
VStack(spacing: 0) {
HStack(spacing: 0) {
statCell(label: "Altitude",
value: aircraft.altitudeFeet.map { "\(formatNumber($0)) ft" } ?? "")
statCell(label: "Speed",
value: aircraft.velocityKnots.map { "\($0) kt" } ?? "")
}
Divider()
HStack(spacing: 0) {
statCell(label: "Heading",
value: aircraft.heading.map { "\($0)°" } ?? "")
statCell(label: "Vertical",
value: verticalDisplay)
}
Divider()
HStack(spacing: 0) {
statCell(label: "Squawk", value: aircraft.squawk ?? "")
statCell(label: "Updated", value: shortTime(aircraft.lastContact))
}
}
.flightCard(padding: 0)
}
private var verticalDisplay: String {
if aircraft.onGround { return "" }
switch aircraft.verticalState {
case .climbing: return "Climb"
case .descending: return "Descend"
case .level: return "Level"
}
}
private func statCell(label: String, value: String) -> some View {
VStack(alignment: .leading, spacing: 2) {
Text(label)
.font(.caption2)
.foregroundStyle(FlightTheme.textTertiary)
.tracking(0.5)
Text(value)
.font(.subheadline.weight(.semibold).monospaced())
.foregroundStyle(FlightTheme.textPrimary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(16)
}
// MARK: - Route
//
// OpenSky's /flights/aircraft endpoint only returns *landed* flights, so
// for a plane currently in flight we usually get nothing relevant the
// most recent entry would be its previous leg (often from yesterday).
//
// To still show something useful, we display whatever's most recent and
// label it clearly: "In flight" if the snapshot is fresh, "Last flight"
// if it's older, "" only when we genuinely have nothing.
@ViewBuilder
private var routeSection: some View {
if let resolved = resolvedRoute {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 8) {
Text(headerLabel(for: resolved))
.font(FlightTheme.label())
.foregroundStyle(FlightTheme.textTertiary)
.tracking(1)
if case .scheduled = resolved {
Circle()
.fill(FlightTheme.onTime)
.frame(width: 6, height: 6)
} else if case .fromFR24 = resolved {
Circle()
.fill(FlightTheme.onTime)
.frame(width: 6, height: 6)
}
}
resolvedRouteCard(resolved)
}
} else if isLoadingRoute {
HStack {
ProgressView()
Text("Resolving route…")
.font(.caption)
.foregroundStyle(FlightTheme.textSecondary)
}
} else {
VStack(alignment: .leading, spacing: 6) {
Text("ROUTE")
.font(FlightTheme.label())
.foregroundStyle(FlightTheme.textTertiary)
.tracking(1)
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)
.frame(maxWidth: .infinity, alignment: .leading)
.background(FlightTheme.cardBackground, in: RoundedRectangle(cornerRadius: 12))
}
}
}
private func headerLabel(for r: ResolvedRoute) -> String {
switch r {
case .fromFR24:
return aircraft.onGround ? "FLIGHT (ON GROUND)" : "IN FLIGHT"
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" }
return "LAST FLIGHT · \(hoursAgo / 24)d AGO"
case .inferred:
return aircraft.onGround ? "ON GROUND" : "IN FLIGHT (INFERRED)"
}
}
@ViewBuilder
private func resolvedRouteCard(_ r: ResolvedRoute) -> some View {
switch r {
case .fromFR24(let dep, let arr, _):
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 16) {
fr24Endpoint(
iata: dep,
label: aircraft.onGround ? "From" : "Departed",
emptyText: "Unknown"
)
Image(systemName: "airplane")
.font(.title3)
.foregroundStyle(FlightTheme.accent)
.rotationEffect(.degrees(-45))
fr24Endpoint(
iata: arr,
label: aircraft.onGround ? "To" : "Heading to",
emptyText: "Unknown"
)
}
}
.flightCard()
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()
}
}
/// Endpoint cell for FR24-sourced routes. Different from
/// `routeEndpoint` because FR24 gives us IATA codes directly (no
/// ICAO-to-IATA conversion) and no scheduled time.
private func fr24Endpoint(iata: String?, label: String, emptyText: String) -> some View {
let code = iata ?? ""
let cityName: String = {
guard let iata, let m = database.airport(byIATA: iata) else { return "" }
return m.name
}()
return VStack(alignment: .leading, spacing: 2) {
Text(label)
.font(.caption2)
.foregroundStyle(FlightTheme.textTertiary)
.tracking(0.5)
Text(code.isEmpty ? "" : code)
.font(FlightTheme.airportCode(28))
.foregroundStyle(code.isEmpty ? FlightTheme.textTertiary : FlightTheme.textPrimary)
if !cityName.isEmpty {
Text(cityName)
.font(.caption2)
.foregroundStyle(FlightTheme.textSecondary)
.lineLimit(1)
} else if code.isEmpty {
Text(emptyText)
.font(.caption2)
.foregroundStyle(FlightTheme.textSecondary)
.lineLimit(1)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
private func routeEndpoint(code: String?, label: String, time: Date) -> some View {
let iata = code.flatMap(icaoToIATA(_:)) ?? code ?? ""
let cityName: String = {
guard let code, let m = database.airport(byIATA: icaoToIATA(code) ?? code) else { return "" }
return m.name
}()
return VStack(alignment: .leading, spacing: 2) {
Text(label)
.font(.caption2)
.foregroundStyle(FlightTheme.textTertiary)
.tracking(0.5)
Text(iata)
.font(FlightTheme.airportCode(28))
.foregroundStyle(FlightTheme.textPrimary)
if !cityName.isEmpty {
Text(cityName)
.font(.caption2)
.foregroundStyle(FlightTheme.textSecondary)
.lineLimit(1)
}
Text(shortDateTime(time))
.font(.caption2.monospaced())
.foregroundStyle(FlightTheme.textTertiary)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
private func recentFlightRow(_ f: OpenSkyFlight) -> some View {
let dep = f.estDepartureAirport.flatMap(icaoToIATA(_:)) ?? f.estDepartureAirport ?? ""
let arr = f.estArrivalAirport.flatMap(icaoToIATA(_:)) ?? f.estArrivalAirport ?? ""
return HStack(spacing: 12) {
Text(dep)
.font(.subheadline.weight(.semibold).monospaced())
.foregroundStyle(FlightTheme.textPrimary)
.frame(width: 56, alignment: .leading)
Image(systemName: "arrow.right")
.font(.caption)
.foregroundStyle(FlightTheme.textTertiary)
Text(arr)
.font(.subheadline.weight(.semibold).monospaced())
.foregroundStyle(FlightTheme.textPrimary)
.frame(width: 56, alignment: .leading)
Spacer()
Text(shortDate(f.departureDate))
.font(.caption.monospaced())
.foregroundStyle(FlightTheme.textSecondary)
}
.padding(.horizontal, 12)
.padding(.vertical, 10)
.background(FlightTheme.cardBackground, in: RoundedRectangle(cornerRadius: 10))
}
// MARK: - Aircraft card
private var aircraftCard: some View {
VStack(spacing: 0) {
HStack(spacing: 0) {
statCell(label: "ICAO24", value: aircraft.icao24.uppercased())
statCell(label: "Country", value: aircraft.originCountry.isEmpty ? "" : aircraft.originCountry)
}
if let cat = aircraft.category, let name = aircraftCategoryName(cat) {
Divider()
HStack(spacing: 0) {
statCell(label: "Category", value: name)
statCell(label: "Position", value: shortCoord(aircraft.coordinate))
}
} else {
Divider()
HStack(spacing: 0) {
statCell(label: "Position", value: shortCoord(aircraft.coordinate))
statCell(label: "", value: "")
}
}
}
.flightCard(padding: 0)
}
// MARK: - Helpers
private var airlineEntry: AircraftRegistry.Entry? {
AircraftRegistry.shared.lookup(icao: aircraft.airlineICAO)
}
private var airlineDisplayName: String {
airlineEntry?.name
?? aircraft.airlineICAO?.uppercased()
?? "Unknown"
}
private func formatNumber(_ n: Int) -> String {
let f = NumberFormatter()
f.numberStyle = .decimal
return f.string(from: NSNumber(value: n)) ?? "\(n)"
}
private func shortCoord(_ c: CLLocationCoordinate2D) -> String {
String(format: "%.2f, %.2f", c.latitude, c.longitude)
}
private func shortTime(_ d: Date) -> String {
let f = DateFormatter()
f.timeStyle = .short
return f.string(from: d)
}
private func shortDate(_ d: Date) -> String {
let f = DateFormatter()
f.dateFormat = "MMM d"
return f.string(from: d)
}
private func shortDateTime(_ d: Date) -> String {
let f = DateFormatter()
f.dateFormat = "MMM d, HH:mm"
return f.string(from: d)
}
/// OpenSky returns 4-letter ICAO airport codes (e.g. "KDFW"). Strip the
/// leading region letter for common 3-letter IATA codes in the US/
/// Canada/etc. Best-effort falls back to the raw value.
private func icaoToIATA(_ icao: String?) -> String? {
guard let icao else { return nil }
let s = icao.uppercased()
guard s.count == 4 else { return s }
// US: KXXX, Canada: CYxx (3 chars after C), Mexico: MMxx (3 chars after M).
if s.hasPrefix("K") { return String(s.dropFirst()) }
if s.hasPrefix("CY") { return String(s.dropFirst()) } // YYZ stays YYZ
if s.hasPrefix("MM") { return String(s.dropFirst()) }
return s
}
}