Live tab: cached filter lists, tighten airline filter, clearer route fallback

Three fixes from the latest round of testing:

1) Tapping the airline filter froze the app for several seconds. Cause:
   `visibleAirlines` / `visibleCategories` were computed properties
   evaluated on every body re-render, each iterating the aircraft array
   and hitting the 2,695-entry registry. Caching them in @State and
   refreshing only via .onChange(of: aircraft) takes the menu-open
   cost to near-zero.

2) Picking "Southwest" in the airline filter still left other carriers
   on screen. Cause: when no callsign-derivable airline ICAO was
   present (N-number GA traffic, ad-hoc callsigns), the filter `guard
   let` silently let the aircraft through. Tightened to require an
   ICAO match when any airline filter is active.

3) The detail sheet showed no departure / arrival airport on most
   selections. Cause: OpenSky's /flights/aircraft endpoint only
   returns flights *after they've landed*, so an in-progress flight
   has no entry. We were waiting for one to appear forever.

   Rewrote the route section: now always shows whatever's most
   recent, labeled "IN FLIGHT" when the snapshot is < 6h old (with a
   green live-dot), "LAST FLIGHT · 3h AGO" otherwise, and an explicit
   "Route not available from OpenSky for this aircraft" card when
   the endpoint returned nothing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trey T
2026-05-27 06:41:22 -05:00
parent 0550376e3d
commit ddfcf3e0e4
2 changed files with 125 additions and 67 deletions
+86 -33
View File
@@ -27,22 +27,8 @@ struct LiveFlightDetailSheet: View {
liveStateGrid liveStateGrid
if let route = currentRoute { routeSection
Text("THIS FLIGHT") .padding(.top, 4)
.font(FlightTheme.label())
.foregroundStyle(FlightTheme.textTertiary)
.tracking(1)
.padding(.top, 4)
routeCard(route)
} else if isLoadingRoute {
HStack {
ProgressView()
Text("Looking up route…")
.font(.caption)
.foregroundStyle(FlightTheme.textSecondary)
}
}
if recentFlights.count > 1 { if recentFlights.count > 1 {
Text("RECENT FLIGHTS") Text("RECENT FLIGHTS")
@@ -178,32 +164,99 @@ struct LiveFlightDetailSheet: View {
} }
// MARK: - Route // 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.
/// The most recent flight (could be in-progress or just-landed). private enum RouteStatus { case loading, inFlight, recent(hoursAgo: Int), none }
private var currentRoute: OpenSkyFlight? {
recentFlights.first { f in @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 now = Date().timeIntervalSince1970
return Double(f.lastSeen) > now - 6 * 3600 let hoursAgo = max(0, Int((now - Double(mostRecent.lastSeen)) / 3600))
} ?? recentFlights.first let status: RouteStatus = {
if aircraft.onGround { return .recent(hoursAgo: hoursAgo) }
if hoursAgo < 6 { return .inFlight }
return .recent(hoursAgo: hoursAgo)
}()
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 8) {
Text(routeHeader(status))
.font(FlightTheme.label())
.foregroundStyle(FlightTheme.textTertiary)
.tracking(1)
if case .inFlight = status {
Circle()
.fill(FlightTheme.onTime)
.frame(width: 6, height: 6)
}
}
routeCard(mostRecent, status: status)
}
} else {
VStack(alignment: .leading, spacing: 6) {
Text("THIS FLIGHT")
.font(FlightTheme.label())
.foregroundStyle(FlightTheme.textTertiary)
.tracking(1)
Text("Route not available from OpenSky for this aircraft.")
.font(.caption)
.foregroundStyle(FlightTheme.textSecondary)
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(FlightTheme.cardBackground, in: RoundedRectangle(cornerRadius: 12))
}
}
} }
private func routeCard(_ f: OpenSkyFlight) -> some View { private func routeHeader(_ status: RouteStatus) -> String {
VStack(alignment: .leading, spacing: 12) { switch status {
case .loading: return "THIS FLIGHT"
case .inFlight: return "IN FLIGHT"
case .recent(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"
}
}
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) { HStack(spacing: 16) {
routeEndpoint( routeEndpoint(code: f.estDepartureAirport, label: depLabel, time: f.departureDate)
code: f.estDepartureAirport,
label: "Departed",
time: f.departureDate
)
Image(systemName: "airplane") Image(systemName: "airplane")
.font(.title3) .font(.title3)
.foregroundStyle(FlightTheme.accent) .foregroundStyle(FlightTheme.accent)
.rotationEffect(.degrees(-45)) .rotationEffect(.degrees(-45))
routeEndpoint( routeEndpoint(code: f.estArrivalAirport, label: arrLabel, time: f.arrivalDate)
code: f.estArrivalAirport,
label: aircraft.onGround ? "Arrived" : "Heading to",
time: f.arrivalDate
)
} }
} }
.flightCard() .flightCard()
+39 -34
View File
@@ -36,6 +36,13 @@ struct LiveFlightsView: View {
@State private var activeSheet: ActiveSheet? @State private var activeSheet: ActiveSheet?
@State private var selectedTrack: AircraftTrack? @State private var selectedTrack: AircraftTrack?
// Cached filter-menu items recomputed only when `aircraft` changes.
// Computing them on every body re-render caused the menu-tap freeze
// (each refresh fed the airlines registry an N×M lookup on the main
// thread).
@State private var cachedAirlineItems: [AirlineFilterItem] = []
@State private var cachedCategoryItems: [CategoryFilterItem] = []
enum ActiveSheet: Identifiable { enum ActiveSheet: Identifiable {
case aircraft(LiveAircraft) case aircraft(LiveAircraft)
case settings case settings
@@ -69,6 +76,7 @@ struct LiveFlightsView: View {
.task(id: selectedAircraft?.icao24) { .task(id: selectedAircraft?.icao24) {
await loadTrackForSelection() await loadTrackForSelection()
} }
.onChange(of: aircraft) { _, _ in rebuildFilterItems() }
.sheet(item: $activeSheet) { sheet in .sheet(item: $activeSheet) { sheet in
switch sheet { switch sheet {
case .aircraft(let ac): case .aircraft(let ac):
@@ -161,7 +169,7 @@ struct LiveFlightsView: View {
Menu { Menu {
Section("Airline") { Section("Airline") {
ForEach(visibleAirlines, id: \.icao) { item in ForEach(cachedAirlineItems, id: \.icao) { item in
Button { Button {
toggle(&selectedAirlineICAO, item.icao) toggle(&selectedAirlineICAO, item.icao)
} label: { } label: {
@@ -188,7 +196,7 @@ struct LiveFlightsView: View {
Menu { Menu {
Section("Aircraft type") { Section("Aircraft type") {
ForEach(visibleCategories, id: \.code) { item in ForEach(cachedCategoryItems, id: \.code) { item in
Button { Button {
toggle(&selectedCategories, item.code) toggle(&selectedCategories, item.code)
} label: { } label: {
@@ -295,10 +303,14 @@ struct LiveFlightsView: View {
let s = searchText.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() let s = searchText.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
return aircraft.filter { ac in return aircraft.filter { ac in
if hideOnGround && ac.onGround { return false } if hideOnGround && ac.onGround { return false }
if !selectedAirlineICAO.isEmpty, if !selectedAirlineICAO.isEmpty {
let code = ac.airlineICAO, // Tightened: require an airline match. Aircraft without a
!selectedAirlineICAO.contains(code) { // parseable ICAO airline prefix (N-numbers, ad-hoc callsigns)
return false // were slipping through as "uncategorized" that's why
// selecting Southwest still left other carriers on screen.
guard let code = ac.airlineICAO, selectedAirlineICAO.contains(code) else {
return false
}
} }
if !selectedCategories.isEmpty { if !selectedCategories.isEmpty {
guard let cat = ac.category, selectedCategories.contains(cat) else { return false } guard let cat = ac.category, selectedCategories.contains(cat) else { return false }
@@ -311,52 +323,45 @@ struct LiveFlightsView: View {
} }
} }
private struct AirlineFilterItem: Hashable { struct AirlineFilterItem: Hashable {
let icao: String let icao: String
let name: String let name: String
let count: Int let count: Int
var label: String { "\(name) (\(count))" } var label: String { "\(name) (\(count))" }
} }
private var visibleAirlines: [AirlineFilterItem] { struct CategoryFilterItem: Hashable {
var seen: [String: Int] = [:]
for ac in aircraft {
if let code = ac.airlineICAO {
seen[code, default: 0] += 1
}
}
// Sort by the displayed airline name (alphabetical), not the ICAO code.
// This is what the user expects when scrolling the filter menu.
return seen.map { (icao, count) in
AirlineFilterItem(
icao: icao,
name: AircraftRegistry.shared.displayName(icao: icao),
count: count
)
}
.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
}
private struct CategoryFilterItem: Hashable {
let code: Int let code: Int
let name: String let name: String
let count: Int let count: Int
var label: String { "\(name) (\(count))" } var label: String { "\(name) (\(count))" }
} }
private var visibleCategories: [CategoryFilterItem] { /// Rebuilds the cached filter items. Called from a .task tied to the
var seen: [Int: Int] = [:] /// aircraft array so it doesn't run on every body re-render.
private func rebuildFilterItems() {
var airlines: [String: Int] = [:]
var cats: [Int: Int] = [:]
for ac in aircraft { for ac in aircraft {
if let code = ac.airlineICAO {
airlines[code, default: 0] += 1
}
if let c = ac.category, c > 0 { if let c = ac.category, c > 0 {
seen[c, default: 0] += 1 cats[c, default: 0] += 1
} }
} }
return seen.compactMap { (code, count) -> CategoryFilterItem? in cachedAirlineItems = airlines.map { (icao, count) in
AirlineFilterItem(
icao: icao,
name: AircraftRegistry.shared.displayName(icao: icao),
count: count
)
}.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
cachedCategoryItems = cats.compactMap { (code, count) -> CategoryFilterItem? in
guard let name = aircraftCategoryName(code) else { return nil } guard let name = aircraftCategoryName(code) else { return nil }
return CategoryFilterItem(code: code, name: name, count: count) return CategoryFilterItem(code: code, name: name, count: count)
} }.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
// Sort by the displayed name, AZ.
.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
} }
// MARK: - Fetch // MARK: - Fetch