History: import flights from CSV (Southwest PNR format)

Adds a "Import CSV…" entry to the History tab's + menu, opening a
file picker → preview → save flow.

- CSVFlightImporter: RFC-4180-ish quote-aware parser + format
  detection. Today only recognizes the Southwest PNR export schema
  (columns Flt No / ORG / DST / Dep Date / OPNG Flt). Returns
  ParsedFlight values with carrier, flight number, route, and
  scheduled departure.
- ImportCSVView: SwiftUI .fileImporter picks a CSV from Files (iCloud
  Drive / On My iPhone / etc.), parses on a Task, dedupes against
  the existing log via FlightHistoryStore.exists(...), shows a
  preview with "N new · M dupes" counts, imports on confirm.
- LoggedFlights created from import store the PNR in notes
  ("PNR: ABC123") and source "csv-import".

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Trey T
2026-05-29 10:27:20 -05:00
parent 9e1dbfbf90
commit d639cdef15
4 changed files with 431 additions and 0 deletions
+8
View File
@@ -76,6 +76,8 @@
HX0E000EEEE000EEEE000001 /* HistoryRouteMapView.swift in Sources */ = {isa = PBXBuildFile; fileRef = HX0E000EEEE000EEEE000002 /* HistoryRouteMapView.swift */; };
HX0F000FFFF000FFFF000001 /* YearInReviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = HX0F000FFFF000FFFF000002 /* YearInReviewView.swift */; };
HX1000001000000010000001 /* AirframeMetadataService.swift in Sources */ = {isa = PBXBuildFile; fileRef = HX1000001000000010000002 /* AirframeMetadataService.swift */; };
HX1100001100000011000001 /* CSVFlightImporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = HX1100001100000011000002 /* CSVFlightImporter.swift */; };
HX1200001200000012000001 /* ImportCSVView.swift in Sources */ = {isa = PBXBuildFile; fileRef = HX1200001200000012000002 /* ImportCSVView.swift */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -161,6 +163,8 @@
HX0E000EEEE000EEEE000002 /* HistoryRouteMapView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryRouteMapView.swift; sourceTree = "<group>"; };
HX0F000FFFF000FFFF000002 /* YearInReviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = YearInReviewView.swift; sourceTree = "<group>"; };
HX1000001000000010000002 /* AirframeMetadataService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirframeMetadataService.swift; sourceTree = "<group>"; };
HX1100001100000011000002 /* CSVFlightImporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CSVFlightImporter.swift; sourceTree = "<group>"; };
HX1200001200000012000002 /* ImportCSVView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImportCSVView.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@@ -207,6 +211,7 @@
HX0D000DDDD000DDDD000002 /* LifetimeStatsView.swift */,
HX0E000EEEE000EEEE000002 /* HistoryRouteMapView.swift */,
HX0F000FFFF000FFFF000002 /* YearInReviewView.swift */,
HX1200001200000012000002 /* ImportCSVView.swift */,
AA5555555555555555555555 /* Styles */,
AA6666666666666666666666 /* Components */,
);
@@ -296,6 +301,7 @@
HX0600006666000066660002 /* CalendarFlightImporter.swift */,
HX0700007777000077770002 /* WalletPassObserver.swift */,
HX1000001000000010000002 /* AirframeMetadataService.swift */,
HX1100001100000011000002 /* CSVFlightImporter.swift */,
);
path = Services;
sourceTree = "<group>";
@@ -487,6 +493,8 @@
HX0E000EEEE000EEEE000001 /* HistoryRouteMapView.swift in Sources */,
HX0F000FFFF000FFFF000001 /* YearInReviewView.swift in Sources */,
HX1000001000000010000001 /* AirframeMetadataService.swift in Sources */,
HX1100001100000011000001 /* CSVFlightImporter.swift in Sources */,
HX1200001200000012000001 /* ImportCSVView.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
+184
View File
@@ -0,0 +1,184 @@
import Foundation
/// Parses a flight-history CSV export into LoggedFlight candidates.
/// Today the only format we detect is Southwest's PNR-level export
/// (the one with columns like `Flt No`, `ORG`, `DST`, `Dep Date`,
/// `OPNG Flt`), which is what the user's existing log is in. Adding
/// another format is mechanically just another `Format` case + a
/// `parseSouthwest`-style mapper.
struct CSVFlightImporter {
enum Format: String, CaseIterable {
case southwest // SWA PNR export
case unknown
}
struct ParsedFlight {
let flightDate: Date
let scheduledDeparture: Date?
let carrierIATA: String?
let carrierICAO: String?
let flightNumber: String?
let departureIATA: String
let arrivalIATA: String
let pnr: String?
}
enum ImportError: LocalizedError {
case unsupportedFormat
case empty
case parseFailed(String)
var errorDescription: String? {
switch self {
case .unsupportedFormat: return "This CSV's column layout isn't one we know yet."
case .empty: return "The file is empty."
case .parseFailed(let s): return "Couldn't parse the file: \(s)"
}
}
}
// MARK: - Entry
static func parse(_ data: Data) throws -> [ParsedFlight] {
guard let text = String(data: data, encoding: .utf8)
?? String(data: data, encoding: .isoLatin1) else {
throw ImportError.parseFailed("not text")
}
let rows = parseRows(text)
guard let header = rows.first, rows.count > 1 else {
throw ImportError.empty
}
switch detect(header: header) {
case .southwest:
return try parseSouthwest(rows: Array(rows.dropFirst()), header: header)
case .unknown:
throw ImportError.unsupportedFormat
}
}
static func detect(header: [String]) -> Format {
let normalized = Set(header.map { $0.trimmingCharacters(in: .whitespaces).lowercased() })
let swKeys: Set<String> = ["flt no", "org", "dst", "dep date", "opng flt"]
if swKeys.isSubset(of: normalized) {
return .southwest
}
return .unknown
}
// MARK: - Southwest mapper
private static func parseSouthwest(rows: [[String]], header: [String]) throws -> [ParsedFlight] {
let index = Dictionary(uniqueKeysWithValues: header.enumerated().map {
($1.trimmingCharacters(in: .whitespaces).lowercased(), $0)
})
func col(_ row: [String], _ key: String) -> String? {
guard let i = index[key], i < row.count else { return nil }
let v = row[i].trimmingCharacters(in: .whitespaces)
return v.isEmpty ? nil : v
}
let depFmt = DateFormatter()
depFmt.dateFormat = "MM/dd/yyyy h:mm a"
depFmt.locale = Locale(identifier: "en_US_POSIX")
depFmt.timeZone = TimeZone(identifier: "UTC") // No tz in the file store as UTC so daily aggregation is stable.
var out: [ParsedFlight] = []
out.reserveCapacity(rows.count)
for row in rows {
guard let depRaw = col(row, "dep date"),
let scheduledDep = depFmt.date(from: depRaw),
let org = col(row, "org"),
let dst = col(row, "dst")
else { continue }
let flightNum = col(row, "flt no")
let pnr = col(row, "pnr no")
// OPNG Flt is "WN1484"; the leading 2 letters are the
// marketing carrier. Default to WN since every row in the
// SW export is Southwest.
let opng = col(row, "opng flt") ?? ""
let carrierIATA = String(opng.prefix(while: { $0.isLetter }))
.uppercased()
.nonEmpty() ?? "WN"
let carrierICAO: String = {
switch carrierIATA {
case "WN": return "SWA"
default: return AircraftRegistry.shared.lookup(iata: carrierIATA)?.icao ?? carrierIATA
}
}()
// Strip the day-of so we can dedupe across the user's
// existing manual entries without worrying about UTC roll.
let day = Calendar(identifier: .gregorian).startOfDay(for: scheduledDep)
out.append(ParsedFlight(
flightDate: day,
scheduledDeparture: scheduledDep,
carrierIATA: carrierIATA,
carrierICAO: carrierICAO,
flightNumber: flightNum,
departureIATA: org.uppercased(),
arrivalIATA: dst.uppercased(),
pnr: pnr
))
}
return out
}
// MARK: - CSV parser
//
// Simple state machine that handles RFC 4180 basics: quoted
// fields, embedded commas inside quotes, and "" " escaping.
// Mac-style and Windows line endings both work because we strip
// CR before splitting on LF.
private static func parseRows(_ text: String) -> [[String]] {
let normalized = text.replacingOccurrences(of: "\r\n", with: "\n")
.replacingOccurrences(of: "\r", with: "\n")
var rows: [[String]] = []
var field = ""
var row: [String] = []
var inQuotes = false
var i = normalized.startIndex
while i < normalized.endIndex {
let c = normalized[i]
if inQuotes {
if c == "\"" {
let next = normalized.index(after: i)
if next < normalized.endIndex && normalized[next] == "\"" {
field.append("\"")
i = next
} else {
inQuotes = false
}
} else {
field.append(c)
}
} else {
switch c {
case "\"":
inQuotes = true
case ",":
row.append(field)
field = ""
case "\n":
row.append(field)
rows.append(row)
row = []
field = ""
default:
field.append(c)
}
}
i = normalized.index(after: i)
}
// Last row (file may not end in newline)
if !field.isEmpty || !row.isEmpty {
row.append(field)
rows.append(row)
}
return rows
}
}
private extension String {
func nonEmpty() -> String? { isEmpty ? nil : self }
}
+7
View File
@@ -19,6 +19,7 @@ struct HistoryView: View {
@State private var showingStats = false
@State private var showingMap = false
@State private var showingCalendarImport = false
@State private var showingCSVImport = false
@State private var showingYearInReview = false
var body: some View {
@@ -67,6 +68,9 @@ struct HistoryView: View {
Button { showingCalendarImport = true } label: {
Label("Scan Calendar", systemImage: "calendar")
}
Button { showingCSVImport = true } label: {
Label("Import CSV…", systemImage: "doc.text")
}
Divider()
Button { showingStats = true } label: {
Label("Lifetime stats", systemImage: "chart.bar.fill")
@@ -108,6 +112,9 @@ struct HistoryView: View {
store: store
)
}
.sheet(isPresented: $showingCSVImport) {
ImportCSVView(store: store)
}
.sheet(isPresented: $showingYearInReview) {
YearInReviewView(stats: stats, year: Calendar.current.component(.year, from: Date()))
}
+232
View File
@@ -0,0 +1,232 @@
import SwiftUI
import UniformTypeIdentifiers
/// File-importer-driven CSV import flow. User picks a CSV from Files
/// (iCloud Drive / On My iPhone / etc.); we detect the format, show a
/// preview with dedupe counts, and on confirm save every novel row as
/// a LoggedFlight. Dupes (same date + flight # + route) are skipped.
struct ImportCSVView: View {
let store: FlightHistoryStore
@Environment(\.dismiss) private var dismiss
@State private var phase: Phase = .picking
@State private var pickedURL: URL?
@State private var parsed: [CSVFlightImporter.ParsedFlight] = []
@State private var novel: [CSVFlightImporter.ParsedFlight] = []
@State private var skipped: Int = 0
@State private var errorText: String?
@State private var importedCount: Int = 0
@State private var showFilePicker = false
enum Phase: Equatable {
case picking
case parsing
case preview
case importing
case done
case failed
}
var body: some View {
NavigationStack {
content
.navigationTitle("Import CSV")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Done") { dismiss() }
}
if phase == .preview && !novel.isEmpty {
ToolbarItem(placement: .primaryAction) {
Button("Import \(novel.count)") { Task { await runImport() } }
}
}
}
.fileImporter(
isPresented: $showFilePicker,
allowedContentTypes: [.commaSeparatedText, .text, .data],
allowsMultipleSelection: false
) { result in
handlePicker(result)
}
.task(id: pickedURL) {
guard let pickedURL else { return }
await runParse(url: pickedURL)
}
}
}
@ViewBuilder
private var content: some View {
switch phase {
case .picking:
VStack(spacing: 16) {
Image(systemName: "doc.text")
.font(.system(size: 56))
.foregroundStyle(FlightTheme.textTertiary)
Text("Choose a CSV file to import")
.font(.headline)
Text("Supported: Southwest PNR export\n(columns Flt No, ORG, DST, Dep Date, OPNG Flt)")
.font(.caption)
.multilineTextAlignment(.center)
.foregroundStyle(FlightTheme.textTertiary)
.padding(.horizontal, 32)
Button {
showFilePicker = true
} label: {
Label("Choose file…", systemImage: "folder")
.font(.subheadline.weight(.semibold))
.padding(.horizontal, 16)
.padding(.vertical, 10)
}
.background(FlightTheme.accent, in: Capsule())
.foregroundStyle(.white)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
case .parsing:
VStack(spacing: 12) {
ProgressView()
Text("Parsing…")
.font(.subheadline)
.foregroundStyle(FlightTheme.textSecondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
case .preview:
previewList
case .importing:
VStack(spacing: 12) {
ProgressView()
Text("Importing \(importedCount) / \(novel.count)")
.font(.subheadline)
.foregroundStyle(FlightTheme.textSecondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
case .done:
ContentUnavailableView(
"Imported \(importedCount) flights",
systemImage: "checkmark.circle.fill",
description: Text(skipped > 0 ? "Skipped \(skipped) duplicates." : "Your log is up to date.")
)
case .failed:
ContentUnavailableView(
"Couldn't read this file",
systemImage: "exclamationmark.triangle.fill",
description: Text(errorText ?? "Try a different CSV.")
)
}
}
private var previewList: some View {
List {
Section {
HStack {
Text("\(parsed.count) rows in file")
Spacer()
Text("\(novel.count) new · \(skipped) dupes")
.foregroundStyle(FlightTheme.textTertiary)
}
.font(.subheadline)
} header: {
Text("Summary")
}
Section {
ForEach(Array(novel.prefix(50).enumerated()), id: \.offset) { _, p in
VStack(alignment: .leading, spacing: 2) {
Text("\(p.carrierIATA ?? "?")\(p.flightNumber ?? "?")")
.font(.subheadline.weight(.semibold).monospaced())
Text("\(p.departureIATA)\(p.arrivalIATA) · \(shortDate(p.flightDate))")
.font(.caption.monospaced())
.foregroundStyle(FlightTheme.textSecondary)
}
}
if novel.count > 50 {
Text("…and \(novel.count - 50) more")
.font(.caption)
.foregroundStyle(FlightTheme.textTertiary)
}
} header: {
Text("Will be imported")
}
}
}
private func handlePicker(_ result: Result<[URL], Error>) {
switch result {
case .success(let urls):
pickedURL = urls.first
case .failure(let error):
errorText = error.localizedDescription
phase = .failed
}
}
private func runParse(url: URL) async {
phase = .parsing
let didStart = url.startAccessingSecurityScopedResource()
defer {
if didStart { url.stopAccessingSecurityScopedResource() }
}
do {
let data = try Data(contentsOf: url)
let rows = try CSVFlightImporter.parse(data)
parsed = rows
var (n, s) = ([CSVFlightImporter.ParsedFlight](), 0)
for r in rows {
let label = "\(r.carrierIATA ?? "")\(r.flightNumber ?? "")"
if store.exists(
flightDate: r.flightDate,
flightLabel: label,
departureIATA: r.departureIATA,
arrivalIATA: r.arrivalIATA
) {
s += 1
} else {
n.append(r)
}
}
novel = n
skipped = s
phase = .preview
} catch {
errorText = error.localizedDescription
phase = .failed
}
}
private func runImport() async {
phase = .importing
importedCount = 0
for p in novel {
let f = LoggedFlight(
flightDate: p.flightDate,
carrierICAO: p.carrierICAO,
carrierIATA: p.carrierIATA,
flightNumber: p.flightNumber,
departureIATA: p.departureIATA,
arrivalIATA: p.arrivalIATA,
scheduledDeparture: p.scheduledDeparture,
scheduledArrival: nil,
aircraftType: nil,
registration: nil,
icao24: nil,
notes: p.pnr.map { "PNR: \($0)" },
source: "csv-import"
)
store.save(f)
importedCount += 1
}
phase = .done
}
private func shortDate(_ d: Date) -> String {
let f = DateFormatter()
f.dateFormat = "MMM d, yyyy"
return f.string(from: d)
}
}