History: filter auto-dismiss, toolbar map access, aircraft empty state, CSV enrichment

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 <noreply@anthropic.com>
This commit is contained in:
Trey T
2026-05-29 18:43:19 -05:00
parent 572e81406d
commit 2e5cf6b9b3
4 changed files with 133 additions and 8 deletions
+52
View File
@@ -14,9 +14,13 @@ struct AircraftStatsView: View {
ScrollView {
VStack(spacing: 16) {
header
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")
+5
View File
@@ -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() }
}
}
}
+9 -1
View File
@@ -95,9 +95,17 @@ struct HistoryView: View {
}
ToolbarItem(placement: .topBarTrailing) {
Menu {
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()))
+61 -1
View File
@@ -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)" },