Enrich existing flights with aircraft types
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 <noreply@anthropic.com>
This commit is contained in:
@@ -85,6 +85,7 @@
|
|||||||
HX1700001700000017000001 /* PassportComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = HX1700001700000017000002 /* PassportComponents.swift */; };
|
HX1700001700000017000001 /* PassportComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = HX1700001700000017000002 /* PassportComponents.swift */; };
|
||||||
HX1800001800000018000001 /* PassportView.swift in Sources */ = {isa = PBXBuildFile; fileRef = HX1800001800000018000002 /* PassportView.swift */; };
|
HX1800001800000018000001 /* PassportView.swift in Sources */ = {isa = PBXBuildFile; fileRef = HX1800001800000018000002 /* PassportView.swift */; };
|
||||||
HX1900001900000019000001 /* AircraftStatsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = HX1900001900000019000002 /* AircraftStatsView.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 */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXContainerItemProxy section */
|
/* Begin PBXContainerItemProxy section */
|
||||||
@@ -179,6 +180,7 @@
|
|||||||
HX1700001700000017000002 /* PassportComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PassportComponents.swift; sourceTree = "<group>"; };
|
HX1700001700000017000002 /* PassportComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PassportComponents.swift; sourceTree = "<group>"; };
|
||||||
HX1800001800000018000002 /* PassportView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PassportView.swift; sourceTree = "<group>"; };
|
HX1800001800000018000002 /* PassportView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PassportView.swift; sourceTree = "<group>"; };
|
||||||
HX1900001900000019000002 /* AircraftStatsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AircraftStatsView.swift; sourceTree = "<group>"; };
|
HX1900001900000019000002 /* AircraftStatsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AircraftStatsView.swift; sourceTree = "<group>"; };
|
||||||
|
HX2000002000000020000002 /* EnrichAircraftTypesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnrichAircraftTypesView.swift; sourceTree = "<group>"; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
@@ -231,6 +233,7 @@
|
|||||||
HX1700001700000017000002 /* PassportComponents.swift */,
|
HX1700001700000017000002 /* PassportComponents.swift */,
|
||||||
HX1800001800000018000002 /* PassportView.swift */,
|
HX1800001800000018000002 /* PassportView.swift */,
|
||||||
HX1900001900000019000002 /* AircraftStatsView.swift */,
|
HX1900001900000019000002 /* AircraftStatsView.swift */,
|
||||||
|
HX2000002000000020000002 /* EnrichAircraftTypesView.swift */,
|
||||||
AA5555555555555555555555 /* Styles */,
|
AA5555555555555555555555 /* Styles */,
|
||||||
AA6666666666666666666666 /* Components */,
|
AA6666666666666666666666 /* Components */,
|
||||||
);
|
);
|
||||||
@@ -523,6 +526,7 @@
|
|||||||
HX1700001700000017000001 /* PassportComponents.swift in Sources */,
|
HX1700001700000017000001 /* PassportComponents.swift in Sources */,
|
||||||
HX1800001800000018000001 /* PassportView.swift in Sources */,
|
HX1800001800000018000001 /* PassportView.swift in Sources */,
|
||||||
HX1900001900000019000001 /* AircraftStatsView.swift in Sources */,
|
HX1900001900000019000001 /* AircraftStatsView.swift in Sources */,
|
||||||
|
HX2000002000000020000001 /* EnrichAircraftTypesView.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,10 +6,13 @@ import SwiftUI
|
|||||||
struct AircraftStatsView: View {
|
struct AircraftStatsView: View {
|
||||||
let allFlights: [LoggedFlight]
|
let allFlights: [LoggedFlight]
|
||||||
let store: FlightHistoryStore
|
let store: FlightHistoryStore
|
||||||
|
let routeExplorer: RouteExplorerClient
|
||||||
|
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@Environment(\.colorScheme) private var scheme
|
@Environment(\.colorScheme) private var scheme
|
||||||
|
|
||||||
|
@State private var showingEnrich = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(spacing: 16) {
|
VStack(spacing: 16) {
|
||||||
@@ -32,6 +35,16 @@ struct AircraftStatsView: View {
|
|||||||
ToolbarItem(placement: .cancellationAction) {
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
Button { dismiss() } label: { Image(systemName: "xmark") }
|
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")
|
Text("No aircraft data yet")
|
||||||
.font(.system(size: 20, weight: .heavy))
|
.font(.system(size: 20, weight: .heavy))
|
||||||
.foregroundStyle(HistoryStyle.ink(scheme))
|
.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. Look it up via the scheduled equipment, or fill in manually.")
|
||||||
Text("Most flights in your log were imported from CSV — that export doesn't include the aircraft type or tail number.")
|
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
.foregroundStyle(HistoryStyle.inkSecondary(scheme))
|
.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))
|
.font(.system(size: 14))
|
||||||
.padding(.horizontal, 24)
|
.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))
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
.background(HistoryStyle.runwayOrange, in: Capsule())
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
}
|
||||||
|
.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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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<Void, Never>?
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -134,7 +134,7 @@ struct HistoryView: View {
|
|||||||
}
|
}
|
||||||
.sheet(isPresented: $showingAircraftStats) {
|
.sheet(isPresented: $showingAircraftStats) {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
AircraftStatsView(allFlights: flights, store: store)
|
AircraftStatsView(allFlights: flights, store: store, routeExplorer: routeExplorer)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showingCalendarImport) {
|
.sheet(isPresented: $showingCalendarImport) {
|
||||||
|
|||||||
Reference in New Issue
Block a user