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
+85 -32
View File
@@ -27,23 +27,9 @@ struct LiveFlightDetailSheet: View {
liveStateGrid
if let route = currentRoute {
Text("THIS FLIGHT")
.font(FlightTheme.label())
.foregroundStyle(FlightTheme.textTertiary)
.tracking(1)
routeSection
.padding(.top, 4)
routeCard(route)
} else if isLoadingRoute {
HStack {
ProgressView()
Text("Looking up route…")
.font(.caption)
.foregroundStyle(FlightTheme.textSecondary)
}
}
if recentFlights.count > 1 {
Text("RECENT FLIGHTS")
.font(FlightTheme.label())
@@ -178,32 +164,99 @@ struct LiveFlightDetailSheet: View {
}
// 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 var currentRoute: OpenSkyFlight? {
recentFlights.first { f in
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
return Double(f.lastSeen) > now - 6 * 3600
} ?? recentFlights.first
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)
}()
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 {
VStack(alignment: .leading, spacing: 12) {
private func routeHeader(_ status: RouteStatus) -> String {
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) {
routeEndpoint(
code: f.estDepartureAirport,
label: "Departed",
time: f.departureDate
)
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: aircraft.onGround ? "Arrived" : "Heading to",
time: f.arrivalDate
)
routeEndpoint(code: f.estArrivalAirport, label: arrLabel, time: f.arrivalDate)
}
}
.flightCard()
+38 -33
View File
@@ -36,6 +36,13 @@ struct LiveFlightsView: View {
@State private var activeSheet: ActiveSheet?
@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 {
case aircraft(LiveAircraft)
case settings
@@ -69,6 +76,7 @@ struct LiveFlightsView: View {
.task(id: selectedAircraft?.icao24) {
await loadTrackForSelection()
}
.onChange(of: aircraft) { _, _ in rebuildFilterItems() }
.sheet(item: $activeSheet) { sheet in
switch sheet {
case .aircraft(let ac):
@@ -161,7 +169,7 @@ struct LiveFlightsView: View {
Menu {
Section("Airline") {
ForEach(visibleAirlines, id: \.icao) { item in
ForEach(cachedAirlineItems, id: \.icao) { item in
Button {
toggle(&selectedAirlineICAO, item.icao)
} label: {
@@ -188,7 +196,7 @@ struct LiveFlightsView: View {
Menu {
Section("Aircraft type") {
ForEach(visibleCategories, id: \.code) { item in
ForEach(cachedCategoryItems, id: \.code) { item in
Button {
toggle(&selectedCategories, item.code)
} label: {
@@ -295,11 +303,15 @@ struct LiveFlightsView: View {
let s = searchText.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
return aircraft.filter { ac in
if hideOnGround && ac.onGround { return false }
if !selectedAirlineICAO.isEmpty,
let code = ac.airlineICAO,
!selectedAirlineICAO.contains(code) {
if !selectedAirlineICAO.isEmpty {
// Tightened: require an airline match. Aircraft without a
// parseable ICAO airline prefix (N-numbers, ad-hoc callsigns)
// 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 {
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 name: String
let count: Int
var label: String { "\(name) (\(count))" }
}
private var visibleAirlines: [AirlineFilterItem] {
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 {
struct CategoryFilterItem: Hashable {
let code: Int
let name: String
let count: Int
var label: String { "\(name) (\(count))" }
}
private var visibleCategories: [CategoryFilterItem] {
var seen: [Int: Int] = [:]
/// Rebuilds the cached filter items. Called from a .task tied to the
/// 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 {
if let code = ac.airlineICAO {
airlines[code, default: 0] += 1
}
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 }
return CategoryFilterItem(code: code, name: name, count: count)
}
// Sort by the displayed name, AZ.
.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
}.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
}
// MARK: - Fetch