From e5333ff9652dddf00e1936288bbdd12890da4e72 Mon Sep 17 00:00:00 2001 From: Trey T Date: Fri, 29 May 2026 18:48:59 -0500 Subject: [PATCH] Enrich existing flights with aircraft types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The user re-imported the CSV expecting the new enrichment-during-import to kick in, but every row dedupe-matched their existing log so nothing got updated. Adds a dedicated "look up missing types" flow for already-saved flights. - EnrichAircraftTypesView: scans every LoggedFlight where aircraftType == nil and carrierIATA + flightNumber are present, walks them sequentially through routeExplorer.searchSchedule for that carrier/flight/date window, patches in equipmentIata when the schedule has a match. Live progress (N / total), found count, Stop to cancel. Saves once at the end so SwiftData batches the writes. - AircraftStatsView now takes a RouteExplorerClient and: - Surfaces a "Look up missing types" CTA button in the empty state (the most useful place to discover this). - Adds a wand.and.stars toolbar button so the flow is reachable even when stats are already populated (for top-ups after new imports). - HistoryView passes the existing routeExplorer through to AircraftStatsView. Net behavior: user opens Aircraft Stats → sees empty state → taps "Look up missing types" → modal scans the log, queries route-explorer per flight, fills in types, hits Done. Stats screen populates without needing a re-import. Co-Authored-By: Claude Opus 4.7 --- Flights.xcodeproj/project.pbxproj | 4 + Flights/Views/AircraftStatsView.swift | 60 +++++-- Flights/Views/EnrichAircraftTypesView.swift | 163 ++++++++++++++++++++ Flights/Views/HistoryView.swift | 2 +- 4 files changed, 211 insertions(+), 18 deletions(-) create mode 100644 Flights/Views/EnrichAircraftTypesView.swift diff --git a/Flights.xcodeproj/project.pbxproj b/Flights.xcodeproj/project.pbxproj index 99638c3..bb7b74f 100644 --- a/Flights.xcodeproj/project.pbxproj +++ b/Flights.xcodeproj/project.pbxproj @@ -85,6 +85,7 @@ HX1700001700000017000001 /* PassportComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = HX1700001700000017000002 /* PassportComponents.swift */; }; HX1800001800000018000001 /* PassportView.swift in Sources */ = {isa = PBXBuildFile; fileRef = HX1800001800000018000002 /* PassportView.swift */; }; HX1900001900000019000001 /* AircraftStatsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = HX1900001900000019000002 /* AircraftStatsView.swift */; }; + HX2000002000000020000001 /* EnrichAircraftTypesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = HX2000002000000020000002 /* EnrichAircraftTypesView.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -179,6 +180,7 @@ HX1700001700000017000002 /* PassportComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PassportComponents.swift; sourceTree = ""; }; HX1800001800000018000002 /* PassportView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PassportView.swift; sourceTree = ""; }; HX1900001900000019000002 /* AircraftStatsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AircraftStatsView.swift; sourceTree = ""; }; + HX2000002000000020000002 /* EnrichAircraftTypesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnrichAircraftTypesView.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -231,6 +233,7 @@ HX1700001700000017000002 /* PassportComponents.swift */, HX1800001800000018000002 /* PassportView.swift */, HX1900001900000019000002 /* AircraftStatsView.swift */, + HX2000002000000020000002 /* EnrichAircraftTypesView.swift */, AA5555555555555555555555 /* Styles */, AA6666666666666666666666 /* Components */, ); @@ -523,6 +526,7 @@ HX1700001700000017000001 /* PassportComponents.swift in Sources */, HX1800001800000018000001 /* PassportView.swift in Sources */, HX1900001900000019000001 /* AircraftStatsView.swift in Sources */, + HX2000002000000020000001 /* EnrichAircraftTypesView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Flights/Views/AircraftStatsView.swift b/Flights/Views/AircraftStatsView.swift index 99200cd..6d4e15b 100644 --- a/Flights/Views/AircraftStatsView.swift +++ b/Flights/Views/AircraftStatsView.swift @@ -6,10 +6,13 @@ import SwiftUI struct AircraftStatsView: View { let allFlights: [LoggedFlight] let store: FlightHistoryStore + let routeExplorer: RouteExplorerClient @Environment(\.dismiss) private var dismiss @Environment(\.colorScheme) private var scheme + @State private var showingEnrich = false + var body: some View { ScrollView { VStack(spacing: 16) { @@ -32,6 +35,16 @@ struct AircraftStatsView: View { ToolbarItem(placement: .cancellationAction) { Button { dismiss() } label: { Image(systemName: "xmark") } } + ToolbarItem(placement: .primaryAction) { + Button { + showingEnrich = true + } label: { + Image(systemName: "wand.and.stars") + } + } + } + .sheet(isPresented: $showingEnrich) { + EnrichAircraftTypesView(store: store, routeExplorer: routeExplorer) } } @@ -50,25 +63,38 @@ struct AircraftStatsView: View { 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") + Text("Most flights in your log were imported from CSV — that export doesn't include the aircraft type. Look it up via the scheduled equipment, or fill in manually.") + .multilineTextAlignment(.center) + .foregroundStyle(HistoryStyle.inkSecondary(scheme)) + .font(.system(size: 14)) + .padding(.horizontal, 24) + + Button { + showingEnrich = true + } label: { + HStack(spacing: 8) { + Image(systemName: "wand.and.stars") + Text("Look up missing types") + .font(.system(size: 15, weight: .heavy)) } - .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 20) + .padding(.vertical, 12) + .background(HistoryStyle.runwayOrange, in: Capsule()) + .foregroundStyle(.white) } - .font(.system(size: 14)) - .padding(.horizontal, 24) + .padding(.top, 8) + + VStack(alignment: .leading, spacing: 8) { + Text("OTHER WAYS") + .font(.system(size: 11, weight: .heavy)) + .tracking(1.3) + .foregroundStyle(HistoryStyle.inkTertiary(scheme)) + .padding(.top, 24) + bullet("Tap an aircraft on the Live tab and \"Add to my flights\"") + bullet("Open any flight and edit the Tail # / Type by hand") + } + .font(.system(size: 13)) + .padding(.horizontal, 28) } } diff --git a/Flights/Views/EnrichAircraftTypesView.swift b/Flights/Views/EnrichAircraftTypesView.swift new file mode 100644 index 0000000..2db2e90 --- /dev/null +++ b/Flights/Views/EnrichAircraftTypesView.swift @@ -0,0 +1,163 @@ +import SwiftUI + +/// Walks every LoggedFlight that lacks an aircraftType, looks the +/// scheduled aircraft up via route-explorer, and patches it in. Use +/// after a CSV import that didn't get aircraft data, or any time the +/// Aircraft Stats screen looks empty. +struct EnrichAircraftTypesView: View { + let store: FlightHistoryStore + let routeExplorer: RouteExplorerClient + + @Environment(\.dismiss) private var dismiss + @Environment(\.colorScheme) private var scheme + + @State private var phase: Phase = .ready + @State private var candidates: [LoggedFlight] = [] + @State private var processedCount = 0 + @State private var enrichedCount = 0 + @State private var skippedDates: [Date] = [] + @State private var task: Task? + + enum Phase { case ready, scanning, running, cancelled, done } + + var body: some View { + NavigationStack { + content + .navigationTitle("Look up aircraft") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(phase == .running ? "Stop" : "Done") { + task?.cancel() + dismiss() + } + } + } + .task { await onAppear() } + } + } + + @ViewBuilder + private var content: some View { + switch phase { + case .ready: + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + + case .scanning: + VStack(spacing: 12) { + ProgressView() + Text("Scanning your log…") + .font(.subheadline) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + + case .running: + VStack(spacing: 18) { + Spacer() + VStack(spacing: 8) { + Text("\(processedCount) of \(candidates.count)") + .font(.system(size: 44, weight: .heavy).monospacedDigit()) + Text("Found aircraft for \(enrichedCount)") + .font(.subheadline) + .foregroundStyle(.secondary) + } + ProgressView(value: Double(processedCount), total: Double(max(candidates.count, 1))) + .progressViewStyle(.linear) + .tint(HistoryStyle.runwayOrange) + .padding(.horizontal, 32) + Spacer() + } + + case .cancelled: + ContentUnavailableView( + "Stopped", + systemImage: "stop.circle", + description: Text("Enriched \(enrichedCount) flights before stopping.") + ) + + case .done: + doneState + } + } + + private var doneState: some View { + VStack(spacing: 14) { + Spacer() + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 56, weight: .heavy)) + .foregroundStyle(HistoryStyle.runwayOrange) + Text("Found aircraft for \(enrichedCount) of \(candidates.count) flights") + .font(.system(size: 18, weight: .heavy)) + .multilineTextAlignment(.center) + .padding(.horizontal, 24) + if candidates.count - enrichedCount > 0 { + Text("Others may be too old for route-explorer's schedule data, or the carrier isn't covered. Manual edit still works for those.") + .font(.caption) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 32) + } + Spacer() + } + } + + private func onAppear() async { + phase = .scanning + candidates = store.allFlights().filter { f in + f.aircraftType == nil + && f.carrierIATA != nil + && (f.flightNumber.flatMap(Int.init) != nil) + } + if candidates.isEmpty { + // Nothing to do — everything already has a type. + phase = .done + return + } + phase = .running + let t = Task { await runEnrichment() } + task = t + await t.value + } + + private func runEnrichment() async { + for f in candidates { + if Task.isCancelled { + phase = .cancelled + return + } + if let eq = await lookupAircraftType(for: f) { + f.aircraftType = eq + enrichedCount += 1 + } + processedCount += 1 + } + // Save once at the end — SwiftData batches writes nicely. + try? store.context.save() + phase = .done + } + + private func lookupAircraftType(for f: LoggedFlight) async -> String? { + guard let carrier = f.carrierIATA, + let numStr = f.flightNumber, + let num = Int(numStr) + else { return nil } + let day = Calendar.current.startOfDay(for: f.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 + ) + // Prefer the result whose dep/arr matches our flight's route + // (some flight numbers fly different routes day to day). + let exact = results.first { + $0.departure.airportIata == f.departureIATA + && $0.arrival.airportIata == f.arrivalIATA + } ?? results.first + guard let eq = exact?.equipmentIata, !eq.isEmpty else { return nil } + return eq.uppercased() + } +} diff --git a/Flights/Views/HistoryView.swift b/Flights/Views/HistoryView.swift index 1ed62a4..8366613 100644 --- a/Flights/Views/HistoryView.swift +++ b/Flights/Views/HistoryView.swift @@ -134,7 +134,7 @@ struct HistoryView: View { } .sheet(isPresented: $showingAircraftStats) { NavigationStack { - AircraftStatsView(allFlights: flights, store: store) + AircraftStatsView(allFlights: flights, store: store, routeExplorer: routeExplorer) } } .sheet(isPresented: $showingCalendarImport) {