From 2e5cf6b9b3828fef73e587cfd5b2cbe5833b3c5d Mon Sep 17 00:00:00 2001 From: Trey T Date: Fri, 29 May 2026 18:43:19 -0500 Subject: [PATCH] History: filter auto-dismiss, toolbar map access, aircraft empty state, CSV enrichment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four fixes the user called out plus the lookup-during-import idea they asked about: 1. Filter sheet auto-dismisses when a year is toggled ON. Years are the user's primary "scope this whole screen" filter — committing one is a commitment, so close the sheet so the underlying view (especially the map animation) can react immediately. Toggling OFF or picking airline/airport/type doesn't dismiss — those are refinement actions. 2. When filtering on the History tab, the hero deck (with the Map / Aircraft / Year-in-Review quick links) hides — leaving no way to reach those screens. Re-added them to the + circle toolbar menu under an "Explore" section, separate from the "Add" section. Always reachable now regardless of filter state. 3. Aircraft Stats screen looked blank when no flights had an aircraftType (CSV imports don't include type). Now shows an explanatory empty state with three concrete paths to populate the data: live tap, manual fill-in, or new manual entries. 4. CSV import now enriches each row with the scheduled aircraft type via routeExplorer.searchSchedule(carrier+flight#+date). Takes the first result that matches the route's dep/arr (or any match for that flight#/day if route doesn't match), pulls equipmentIata as the aircraft type. Best-effort: old flights without schedule data, unmappable carriers, or network failures are silently skipped — the flight still saves without a type. Preview screen has a "Look up aircraft type" toggle (on by default). Importing-phase shows live count of enriched rows. ImportCSVView now takes a RouteExplorerClient; HistoryView passes the existing instance. Co-Authored-By: Claude Opus 4.7 --- Flights/Views/AircraftStatsView.swift | 58 ++++++++++++++++++++++-- Flights/Views/HistoryFilterSheet.swift | 5 +++ Flights/Views/HistoryView.swift | 16 +++++-- Flights/Views/ImportCSVView.swift | 62 +++++++++++++++++++++++++- 4 files changed, 133 insertions(+), 8 deletions(-) diff --git a/Flights/Views/AircraftStatsView.swift b/Flights/Views/AircraftStatsView.swift index 5eff405..99200cd 100644 --- a/Flights/Views/AircraftStatsView.swift +++ b/Flights/Views/AircraftStatsView.swift @@ -14,9 +14,13 @@ struct AircraftStatsView: View { ScrollView { VStack(spacing: 16) { header - topStatsRow - typeListSection - mostFlownTailSection + if hasAnyAircraftData { + topStatsRow + typeListSection + mostFlownTailSection + } else { + emptyState + } Spacer(minLength: 60) } .padding(.horizontal, 16) @@ -31,6 +35,54 @@ struct AircraftStatsView: View { } } + /// True when at least one logged flight has an aircraft type OR + /// a registration we could surface. + private var hasAnyAircraftData: Bool { + !uniqueTypeCodes.isEmpty || mostFlownTail != nil + } + + private var emptyState: some View { + VStack(spacing: 16) { + Image(systemName: "airplane.circle") + .font(.system(size: 56, weight: .heavy)) + .foregroundStyle(HistoryStyle.runwayOrange) + .padding(.top, 32) + Text("No aircraft data yet") + .font(.system(size: 20, weight: .heavy)) + .foregroundStyle(HistoryStyle.ink(scheme)) + VStack(spacing: 10) { + Text("Most flights in your log were imported from CSV — that export doesn't include the aircraft type or tail number.") + .multilineTextAlignment(.center) + .foregroundStyle(HistoryStyle.inkSecondary(scheme)) + Text("To populate this screen:") + .font(.system(size: 13, weight: .heavy)) + .tracking(1.2) + .textCase(.uppercase) + .foregroundStyle(HistoryStyle.inkTertiary(scheme)) + .padding(.top, 12) + VStack(alignment: .leading, spacing: 8) { + bullet("Tap an aircraft in the Live tab and use \"Add to my flights\"") + bullet("Open any logged flight and fill in Tail # / Type by hand") + bullet("New manual entries via the + menu let you set the type") + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .font(.system(size: 14)) + .padding(.horizontal, 24) + } + } + + private func bullet(_ text: String) -> some View { + HStack(alignment: .top, spacing: 10) { + Circle() + .fill(HistoryStyle.runwayOrange) + .frame(width: 6, height: 6) + .padding(.top, 6) + Text(text) + .foregroundStyle(HistoryStyle.inkSecondary(scheme)) + } + } + private var header: some View { VStack(spacing: 4) { Text("AIRCRAFT") diff --git a/Flights/Views/HistoryFilterSheet.swift b/Flights/Views/HistoryFilterSheet.swift index bd2fdf2..d610b24 100644 --- a/Flights/Views/HistoryFilterSheet.swift +++ b/Flights/Views/HistoryFilterSheet.swift @@ -28,6 +28,11 @@ struct HistoryFilterSheet: View { toggleRow(label: String(o.value), count: o.count, isOn: filters.years.contains(o.value)) { on in toggle(&filters.years, o.value, on) + // Years are the user's primary "scope this + // whole screen" filter — picking one is a + // commitment, so close the sheet so the + // animation can play immediately. + if on { dismiss() } } } } diff --git a/Flights/Views/HistoryView.swift b/Flights/Views/HistoryView.swift index b3edc1a..1ed62a4 100644 --- a/Flights/Views/HistoryView.swift +++ b/Flights/Views/HistoryView.swift @@ -95,9 +95,17 @@ struct HistoryView: View { } ToolbarItem(placement: .topBarTrailing) { Menu { - Button { showingAdd = true } label: { Label("Add manually", systemImage: "plus") } - Button { showingCalendarImport = true } label: { Label("Scan Calendar", systemImage: "calendar") } - Button { showingCSVImport = true } label: { Label("Import CSV…", systemImage: "doc.text") } + Section("Add") { + Button { showingAdd = true } label: { Label("Add manually", systemImage: "plus") } + Button { showingCalendarImport = true } label: { Label("Scan Calendar", systemImage: "calendar") } + Button { showingCSVImport = true } label: { Label("Import CSV…", systemImage: "doc.text") } + } + Section("Explore") { + Button { showingPassport = true } label: { Label("Passport", systemImage: "book.closed") } + Button { showingMap = true } label: { Label("Route map", systemImage: "map.fill") } + Button { showingAircraftStats = true } label: { Label("Aircraft stats", systemImage: "airplane.circle") } + Button { showingYearInReview = true } label: { Label("Year in Review", systemImage: "sparkles") } + } } label: { Image(systemName: "plus.circle.fill") .foregroundStyle(HistoryStyle.runwayOrange) @@ -133,7 +141,7 @@ struct HistoryView: View { CalendarImportView(routeExplorer: routeExplorer, database: database, store: store) } .sheet(isPresented: $showingCSVImport) { - ImportCSVView(store: store) + ImportCSVView(store: store, routeExplorer: routeExplorer) } .sheet(isPresented: $showingYearInReview) { YearInReviewView(stats: stats, year: selectedYear ?? Calendar.current.component(.year, from: Date())) diff --git a/Flights/Views/ImportCSVView.swift b/Flights/Views/ImportCSVView.swift index c84db1f..9e6180b 100644 --- a/Flights/Views/ImportCSVView.swift +++ b/Flights/Views/ImportCSVView.swift @@ -7,6 +7,7 @@ import UniformTypeIdentifiers /// a LoggedFlight. Dupes (same date + flight # + route) are skipped. struct ImportCSVView: View { let store: FlightHistoryStore + let routeExplorer: RouteExplorerClient @Environment(\.dismiss) private var dismiss @@ -17,6 +18,8 @@ struct ImportCSVView: View { @State private var skipped: Int = 0 @State private var errorText: String? @State private var importedCount: Int = 0 + @State private var enrichedCount: Int = 0 + @State private var enrichEnabled: Bool = true @State private var showFilePicker = false enum Phase: Equatable { @@ -103,6 +106,11 @@ struct ImportCSVView: View { Text("Importing \(importedCount) / \(novel.count)…") .font(.subheadline) .foregroundStyle(FlightTheme.textSecondary) + if enrichEnabled { + Text("Found aircraft type for \(enrichedCount)") + .font(.caption) + .foregroundStyle(FlightTheme.textTertiary) + } } .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -135,6 +143,19 @@ struct ImportCSVView: View { } header: { Text("Summary") } + Section { + Toggle(isOn: $enrichEnabled) { + VStack(alignment: .leading, spacing: 2) { + Text("Look up aircraft type") + .font(.subheadline.weight(.semibold)) + Text("Adds a few seconds per flight via route-explorer schedule data. Old flights may not match.") + .font(.caption) + .foregroundStyle(FlightTheme.textSecondary) + } + } + } header: { + Text("Enrichment") + } Section { ForEach(Array(novel.prefix(50).enumerated()), id: \.offset) { _, p in VStack(alignment: .leading, spacing: 2) { @@ -166,6 +187,35 @@ struct ImportCSVView: View { } } + /// Look up the scheduled aircraft type from route-explorer for one + /// parsed row. Returns nil for old flights (no schedule data), + /// unmappable carriers, or network failures — those cases are + /// expected and we just save the flight without a type. + private func lookupAircraftType(for p: CSVFlightImporter.ParsedFlight) async -> String? { + guard let carrier = p.carrierIATA, + let numStr = p.flightNumber, + let num = Int(numStr) + else { return nil } + let day = Calendar.current.startOfDay(for: p.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 + ) + // Match the route too (some flight numbers fly different + // routes on different days; we want the one matching the + // user's dep/arr). + let exact = results.first { + $0.departure.airportIata == p.departureIATA + && $0.arrival.airportIata == p.arrivalIATA + } ?? results.first + let eq = exact?.equipmentIata + guard let eq, !eq.isEmpty else { return nil } + return eq.uppercased() + } + private func runParse(url: URL) async { phase = .parsing let didStart = url.startAccessingSecurityScopedResource() @@ -202,7 +252,17 @@ struct ImportCSVView: View { private func runImport() async { phase = .importing importedCount = 0 + enrichedCount = 0 for p in novel { + // Optionally look up the scheduled aircraft type via + // route-explorer for this carrier/flight/date so the + // Aircraft stats screen has data even from a bare CSV. + // Best-effort: silently skip on failure or no match. + let enrichedType: String? = enrichEnabled + ? await lookupAircraftType(for: p) + : nil + if enrichedType != nil { enrichedCount += 1 } + let f = LoggedFlight( flightDate: p.flightDate, carrierICAO: p.carrierICAO, @@ -212,7 +272,7 @@ struct ImportCSVView: View { arrivalIATA: p.arrivalIATA, scheduledDeparture: p.scheduledDeparture, scheduledArrival: nil, - aircraftType: nil, + aircraftType: enrichedType, registration: nil, icao24: nil, notes: p.pnr.map { "PNR: \($0)" },