Flight detail: bearing-aware icons + auto type lookup

Icons:
- Route card's central airplane and the two map markers now rotate
  by the actual dep→arr compass bearing instead of a hardcoded -45°.
  DAL→SAN (westbound) shows planes pointing left; LAS→DAL (eastbound)
  points right; DAL→HOU (south) points down. New
  HistoryDetailView.bearing(from:to:) computes great-circle bearing;
  rotation is (bearing - 45) since SF's airplane glyph naturally
  sits at ~45°.
- Replaced the FlightRouteMap's `Marker(systemImage:)` for departure
  and arrival with custom `Annotation` views (routeMarker helper)
  that wrap an SF airplane.departure/arrival glyph in a tinted
  circle and rotate the icon by the same bearing.

Aircraft data lookup:
- HistoryDetailView now auto-enriches the missing aircraftType on
  .task via the same two-step chain the bulk Aircraft Stats button
  uses: route-explorer first, FlightAware activity-log fallback,
  normalized through AircraftDatabase.normalizedICAO before saving.
- Aircraft card's Type cell now shows the friendly name when
  available — "B738 · Boeing 737-800" instead of just "B738".
- When neither registration nor icao24 is set (typical for CSV-
  imported historical flights), the card shows an honest one-line
  caption explaining why tail / first-flight / ICAO24 are blank
  and what populates them.

Honest about what we can't get:
- Tested OpenSky /flights/departure (anonymous tier blocks
  historical, 403 "You cannot access historical flights"),
  FlightAware /history/<date>/<time>Z/... (needs exact UTC
  scheduled departure time we don't have for CSV rows), JetPhotos
  and Planespotters flight search (Cloudflare-gated). For
  historical Southwest flights from a PNR-style CSV, the most we
  can pull for free is the typical aircraft type for that flight
  number from FlightAware's activity log — which we now do
  automatically when the detail view appears.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Trey T
2026-05-31 13:13:50 -05:00
parent cb981c5380
commit 5c1d7871c6
2 changed files with 131 additions and 9 deletions
+130 -8
View File
@@ -12,6 +12,7 @@ struct HistoryDetailView: View {
let store: FlightHistoryStore
let database: AirportDatabase
let openSky: OpenSkyClient
var routeExplorer: RouteExplorerClient? = nil
@Environment(\.dismiss) private var dismiss
@Environment(\.openURL) private var openURL
@@ -50,6 +51,7 @@ struct HistoryDetailView: View {
}
await loadTrackIfRecent()
await loadAirframeMetadata()
await enrichAircraftTypeIfMissing()
}
.alert("Delete this flight?", isPresented: $showDeleteConfirm) {
Button("Delete", role: .destructive) {
@@ -85,6 +87,26 @@ struct HistoryDetailView: View {
?? flight.carrierIATA ?? "Unknown"
}
/// Compass bearing from departure to arrival airport. Falls back to
/// 90° (eastbound) when we can't resolve coordinates so the icon
/// just stays in a sensible default.
private var routeBearing: Double {
guard let dep = database.airport(byIATA: flight.departureIATA),
let arr = database.airport(byIATA: flight.arrivalIATA)
else { return 90 }
return Self.bearing(from: dep.coordinate, to: arr.coordinate)
}
static func bearing(from a: CLLocationCoordinate2D, to b: CLLocationCoordinate2D) -> Double {
let lat1 = a.latitude * .pi / 180
let lat2 = b.latitude * .pi / 180
let dLon = (b.longitude - a.longitude) * .pi / 180
let y = sin(dLon) * cos(lat2)
let x = cos(lat1) * sin(lat2) - sin(lat1) * cos(lat2) * cos(dLon)
let theta = atan2(y, x)
return (theta * 180 / .pi + 360).truncatingRemainder(dividingBy: 360)
}
// MARK: - Route
private var routeCard: some View {
@@ -95,7 +117,10 @@ struct HistoryDetailView: View {
Image(systemName: "airplane")
.font(.system(size: 24, weight: .heavy))
.foregroundStyle(HistoryStyle.runwayOrange)
.rotationEffect(.degrees(-45))
// SF airplane symbol naturally points up-right
// (~45°). To align with the actual travel
// bearing, rotate by (bearing - 45).
.rotationEffect(.degrees(routeBearing - 45))
Spacer()
routeEndpoint(iata: flight.arrivalIATA, label: "To", time: flight.actualArrival ?? flight.scheduledArrival)
}
@@ -200,6 +225,54 @@ struct HistoryDetailView: View {
track = await openSky.track(icao24: icao24)
}
/// If the flight is missing its aircraft type, run the same
/// two-step lookup the bulk enricher does: route-explorer for
/// future flights, FlightAware activity-log scrape for historical.
/// Result is normalized to ICAO and patched onto the LoggedFlight.
/// No-op when type is already set.
private func enrichAircraftTypeIfMissing() async {
guard flight.aircraftType == nil || flight.aircraftType?.isEmpty == true else { return }
guard let carrier = flight.carrierIATA,
let numStr = flight.flightNumber,
let num = Int(numStr)
else { return }
// 1) route-explorer (works for future schedules)
if let routeExplorer {
let day = Calendar.current.startOfDay(for: flight.flightDate)
let next = Calendar.current.date(byAdding: .day, value: 1, to: day) ?? day
let results = await routeExplorer.searchSchedule(
carrierCode: carrier,
flightNumber: num,
startDate: day,
endDate: next
)
let exact = results.first {
$0.departure.airportIata == flight.departureIATA
&& $0.arrival.airportIata == flight.arrivalIATA
} ?? results.first
if let eq = exact?.equipmentIata, !eq.isEmpty {
flight.aircraftType = AircraftDatabase.shared.normalizedICAO(forCode: eq)
try? store.context.save()
return
}
}
// 2) FlightAware activity-log fallback
guard let carrierICAO = flight.carrierICAO
?? AircraftRegistry.shared.lookup(iata: carrier)?.icao
else { return }
let callsign = "\(carrierICAO)\(num)"
if let icaoType = await FlightAwareLookup.shared.lookupType(
callsign: callsign,
departureIATA: flight.departureIATA,
arrivalIATA: flight.arrivalIATA
) {
flight.aircraftType = AircraftDatabase.shared.normalizedICAO(forCode: icaoType)
try? store.context.save()
}
}
private func loadAirframeMetadata() async {
guard let reg = flight.registration, !reg.isEmpty,
let icao24 = flight.icao24, !icao24.isEmpty
@@ -226,12 +299,17 @@ struct HistoryDetailView: View {
let airframe = flight.registration.flatMap(store.airframe(for:))
let firstFlight = airframe?.firstFlightDate
let ageYears = firstFlight.map { years(since: $0) }
let typeDisplay: String = {
guard let code = flight.aircraftType, !code.isEmpty else { return "" }
let friendly = AircraftDatabase.shared.displayName(forTypeCode: code)
return friendly == code ? code : "\(code) · \(friendly)"
}()
return VStack(alignment: .leading, spacing: 12) {
HistorySectionLabel("Aircraft")
VStack(spacing: 0) {
aircraftRow(
leftLabel: "Type", leftValue: flight.aircraftType ?? "",
leftLabel: "Type", leftValue: typeDisplay,
rightLabel: "Tail #", rightValue: flight.registration ?? ""
)
divider
@@ -250,6 +328,17 @@ struct HistoryDetailView: View {
)
}
.historyCard(scheme, padding: 0)
// Honest note: tail / icao24 / first-flight come from
// OpenSky metadata which requires icao24 only captured
// when the flight is added via the Live tab. Historical
// CSV imports won't have these.
if flight.registration == nil && flight.icao24 == nil {
Text("Tail #, first-flight date and ICAO24 aren't available for this flight — those come from the live ADS-B feed when you tap a plane on the Live tab. Aircraft type was looked up from FlightAware's recent activity for this flight number.")
.font(.caption2)
.foregroundStyle(HistoryStyle.inkTertiary(scheme))
.padding(.top, 4)
}
}
}
@@ -449,14 +538,28 @@ private struct FlightRouteMap: View {
let database: AirportDatabase
var body: some View {
let dep = database.airport(byIATA: departureIATA)?.coordinate
let arr = database.airport(byIATA: arrivalIATA)?.coordinate
let bearing = (dep != nil && arr != nil)
? HistoryDetailView.bearing(from: dep!, to: arr!)
: 90
Map {
if let dep = database.airport(byIATA: departureIATA) {
Marker("From " + departureIATA, systemImage: "airplane.departure", coordinate: dep.coordinate)
.tint(HistoryStyle.stampGreen)
if let dep {
Annotation("From " + departureIATA, coordinate: dep) {
routeMarker(systemName: "airplane.departure",
bearing: bearing,
tint: HistoryStyle.stampGreen)
}
.annotationTitles(.hidden)
}
if let arr = database.airport(byIATA: arrivalIATA) {
Marker("To " + arrivalIATA, systemImage: "airplane.arrival", coordinate: arr.coordinate)
.tint(HistoryStyle.runwayOrange)
if let arr {
Annotation("To " + arrivalIATA, coordinate: arr) {
routeMarker(systemName: "airplane.arrival",
bearing: bearing,
tint: HistoryStyle.runwayOrange)
}
.annotationTitles(.hidden)
}
if let track {
let coords = track.path.map {
@@ -472,6 +575,25 @@ private struct FlightRouteMap: View {
}
}
/// Map-pin chrome for an airport endpoint. Wraps the SF symbol
/// in a circle background, rotates the icon so it points in the
/// actual flight direction (so a DALSAN flight shows planes
/// pointing left, not the default right).
@ViewBuilder
private func routeMarker(systemName: String, bearing: Double, tint: Color) -> some View {
Image(systemName: systemName)
.font(.system(size: 14, weight: .black))
.foregroundStyle(.white)
// SF airplane.departure/airplane.arrival glyphs orient
// roughly up-right at ~45°; rotation matches the central
// route icon's correction.
.rotationEffect(.degrees(bearing - 45))
.frame(width: 30, height: 30)
.background(tint, in: Circle())
.overlay(Circle().stroke(.white, lineWidth: 1.5))
.shadow(color: .black.opacity(0.4), radius: 2, y: 1)
}
private func greatCircle(from a: CLLocationCoordinate2D, to b: CLLocationCoordinate2D, segments: Int) -> [CLLocationCoordinate2D] {
let lat1 = a.latitude * .pi / 180
let lon1 = a.longitude * .pi / 180
+1 -1
View File
@@ -398,7 +398,7 @@ struct HistoryView: View {
}
ForEach(group.flights) { f in
NavigationLink {
HistoryDetailView(flight: f, store: store, database: database, openSky: openSky)
HistoryDetailView(flight: f, store: store, database: database, openSky: openSky, routeExplorer: routeExplorer)
} label: {
PassportFlightRow(flight: f, database: database)
.padding(.horizontal, 16)