diff --git a/Package.swift b/Package.swift index 493d355..8fee339 100644 --- a/Package.swift +++ b/Package.swift @@ -3,21 +3,21 @@ import PackageDescription let package = Package( - name: "AmtrakStatus", - platforms: [.macOS(.v10_15), .iOS(.v13), .tvOS(.v13), .macCatalyst(.v13)], - dependencies: [ - .package( - url: "https://github.com/moreSwift/swift-cross-ui", - .upToNextMinor(from: "0.7.0"), - ), - ], - targets: [ - .executableTarget( - name: "AmtrakStatus", - dependencies: [ - .product(name: "SwiftCrossUI", package: "swift-cross-ui"), - .product(name: "DefaultBackend", package: "swift-cross-ui"), - ], - ), - ], + name: "AmtrakStatus", + platforms: [.macOS(.v10_15), .iOS(.v13), .tvOS(.v13), .macCatalyst(.v13)], + dependencies: [ + .package( + url: "https://github.com/moreSwift/swift-cross-ui", + .upToNextMinor(from: "0.7.0"), + ) + ], + targets: [ + .executableTarget( + name: "AmtrakStatus", + dependencies: [ + .product(name: "SwiftCrossUI", package: "swift-cross-ui"), + .product(name: "DefaultBackend", package: "swift-cross-ui"), + ], + ) + ], ) diff --git a/Sources/AmtrakStatus/AmtrakClient.swift b/Sources/AmtrakStatus/AmtrakClient.swift index cd0a00d..f78f907 100644 --- a/Sources/AmtrakStatus/AmtrakClient.swift +++ b/Sources/AmtrakStatus/AmtrakClient.swift @@ -1,87 +1,85 @@ import Foundation enum AmtrakError: Error { - case invalidURL(String) - case invalidResponse - case httpStatus(Int) - case stationNotFound(String) + case invalidURL(String) + case invalidResponse + case httpStatus(Int) + case stationNotFound(String) } - struct TrainStationLookUp: Sendable { - var trainNumbers: [String] - var timeZone: TimeZone? + var trainNumbers: [String] + var timeZone: TimeZone? } struct AmtrakClient { - var decoder = JSONDecoder() + var decoder = JSONDecoder() - let stationBaseURL = "https://api.amtraker.com/v3/stations" - let trainsBaseURL = "https://api.amtraker.com/v3/trains" - let staleBaseURL = "https://api.amtraker.com/v3/stale" + let stationBaseURL = "https://api.amtraker.com/v3/stations" + let trainsBaseURL = "https://api.amtraker.com/v3/trains" + let staleBaseURL = "https://api.amtraker.com/v3/stale" - func fetchTrain(number trainid: String) async throws -> TrainResponse { - try await get("\(trainsBaseURL)/\(trainid)") + func fetchTrain(number trainid: String) async throws -> TrainResponse { + try await get("\(trainsBaseURL)/\(trainid)") + } + + func fetchAllTrains() async throws -> TrainResponse { + try await get(trainsBaseURL) + } + + func fetchStaleStatus() async throws -> StaleData { + try await get(staleBaseURL) + } + + func fetchAllTrainStatus(num trainNum: String) async throws -> [Train] { + let reponse = try await fetchTrain(number: trainNum) + return reponse.values.flatMap(\.self) + } + + func fetchStation(code stationid: String) async throws -> StationResponse { + try await get("\(stationBaseURL)/\(stationid)") + } + + func fetchAllStations() async throws -> StationResponse { + try await get(stationBaseURL) + } + + func trainNumber(fromTrainId trainId: String) -> String { + trainId + .split(separator: "-") + .first + .map(String.init) ?? trainId + } + + func trainStationLookup(stationCode code: String) async throws -> TrainStationLookUp { + let reponse = try await fetchStation(code: code) + + guard let meta = reponse[code] else { + throw AmtrakError.stationNotFound(code) } - func fetchAllTrains() async throws -> TrainResponse { - try await get(trainsBaseURL) - } - - func fetchStaleStatus() async throws -> StaleData { - try await get(staleBaseURL) + let trainNumbers = Array(Set(meta.trains.map(trainNumber(fromTrainId:)))) + let timeZone = meta.tz.flatMap(TimeZone.init(identifier:)) + return TrainStationLookUp( + trainNumbers: trainNumbers, + timeZone: timeZone + ) + } + + func get(_ urlString: String) async throws -> T { + guard let url = URL(string: urlString) else { + throw AmtrakError.invalidURL(urlString) } - func fetchAllTrainStatus(num trainNum: String) async throws -> [Train] { - let reponse = try await fetchTrain(number: trainNum) - return reponse.values.flatMap(\.self) + let (data, reponse) = try await URLSession.shared.data(from: url) + guard let http = reponse as? HTTPURLResponse else { + throw AmtrakError.invalidResponse } - func fetchStation(code stationid: String) async throws -> StationResponse { - try await get("\(stationBaseURL)/\(stationid)") + guard (200..<300).contains(http.statusCode) else { + throw AmtrakError.httpStatus(http.statusCode) } - func fetchAllStations() async throws -> StationResponse { - try await get(stationBaseURL) - } - - func trainNumber(fromTrainId trainId: String) -> String { - trainId - .split(separator: "-") - .first - .map(String.init) ?? trainId - } - - - func trainStationLookup(stationCode code: String) async throws -> TrainStationLookUp { - let reponse = try await fetchStation(code: code) - - guard let meta = reponse[code] else { - throw AmtrakError.stationNotFound(code) - } - - let trainNumbers = Array(Set(meta.trains.map(trainNumber(fromTrainId:)))) - let timeZone = meta.tz.flatMap(TimeZone.init(identifier:)) - return TrainStationLookUp( - trainNumbers: trainNumbers, - timeZone: timeZone - ) - } - - func get(_ urlString: String) async throws -> T { - guard let url = URL(string: urlString) else { - throw AmtrakError.invalidURL(urlString) - } - - let (data, reponse) = try await URLSession.shared.data(from: url) - guard let http = reponse as? HTTPURLResponse else { - throw AmtrakError.invalidResponse - } - - guard (200 ..< 300).contains(http.statusCode) else { - throw AmtrakError.httpStatus(http.statusCode) - } - - return try decoder.decode(T.self, from: data) - } + return try decoder.decode(T.self, from: data) + } } diff --git a/Sources/AmtrakStatus/AmtrakDateFormatting.swift b/Sources/AmtrakStatus/AmtrakDateFormatting.swift index d7b379e..4caa252 100644 --- a/Sources/AmtrakStatus/AmtrakDateFormatting.swift +++ b/Sources/AmtrakStatus/AmtrakDateFormatting.swift @@ -1,93 +1,99 @@ import Foundation struct AmtrakDateFormatting { - let isoFormatter = ISO8601DateFormatter() - - let timeFormatter: DateFormatter = { - let formatter = DateFormatter() - formatter.dateStyle = .none - formatter.timeStyle = .short - return formatter - }() - - let dateFormatter: DateFormatter = { - let formatter = DateFormatter() - formatter.dateStyle = .medium - formatter.timeStyle = .none - return formatter - }() - - func parsedDate(from isoString: String?) -> Date? { - guard let isoString, !isoString.isEmpty, let date = isoFormatter.date( - from: isoString) else { - return nil - } - - return isoFormatter.date(from: isoString) + let isoFormatter = ISO8601DateFormatter() + + let timeFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateStyle = .none + formatter.timeStyle = .short + return formatter + }() + + let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .none + return formatter + }() + + func parsedDate(from isoString: String?) -> Date? { + guard let isoString, !isoString.isEmpty, + let date = isoFormatter.date( + from: isoString) + else { + return nil } - - func time(from isoString: String?, in zone: TimeZone?, comparedTo originZone: TimeZone? = nil) -> String? { - guard let isoString, !isoString.isEmpty, let date = isoFormatter.date( - from: isoString) else { - return nil - } - - timeFormatter.timeZone = zone - - let formatted = timeFormatter.string(from: date) - - guard - let zone, - let originZone, - zone.identifier != originZone.identifier, - let abbreviation = zone.abbreviation(for: date) - else { - return formatted - } - - return "\(formatted) \(abbreviation)" + + return isoFormatter.date(from: isoString) + } + + func time(from isoString: String?, in zone: TimeZone?, comparedTo originZone: TimeZone? = nil) + -> String? + { + guard let isoString, !isoString.isEmpty, + let date = isoFormatter.date( + from: isoString) + else { + return nil } - - - func isOnTime(actual: String?, scheduled: String?) -> LateTrain { - - guard - let actualDate = parsedDate(from: actual), - let scheduledDate = parsedDate(from: scheduled) - else { - return .unknown - } - - let differenceInMinutes = Int(actualDate.timeIntervalSince(scheduledDate) / 60) - - switch differenceInMinutes { - case -1...1: - return .onTime - case ..<0: - return .early(minutes: -differenceInMinutes) - default: - return .late(minutes: differenceInMinutes) - } + + timeFormatter.timeZone = zone + + let formatted = timeFormatter.string(from: date) + + guard + let zone, + let originZone, + zone.identifier != originZone.identifier, + let abbreviation = zone.abbreviation(for: date) + else { + return formatted } - - - func localTime(timeZone: TimeZone?) -> String { - let now = Date() - timeFormatter.timeZone = timeZone - return timeFormatter.string(from: now) + + return "\(formatted) \(abbreviation)" + } + + func isOnTime(actual: String?, scheduled: String?) -> LateTrain { + + guard + let actualDate = parsedDate(from: actual), + let scheduledDate = parsedDate(from: scheduled) + else { + return .unknown } - - func updateTime(timeZone: TimeZone?) { - let now = Date() - timeFormatter.timeZone = timeZone + + let differenceInMinutes = Int(actualDate.timeIntervalSince(scheduledDate) / 60) + + switch differenceInMinutes { + case -1...1: + return .onTime + case ..<0: + return .early(minutes: -differenceInMinutes) + default: + return .late(minutes: differenceInMinutes) } - - func date(from isoString: String?) -> String? { - guard let isoString, !isoString.isEmpty, let date = isoFormatter.date( - from: isoString) else { - return nil - } - - return dateFormatter.string(from: date) + } + + func localTime(timeZone: TimeZone?) -> String { + let now = Date() + timeFormatter.timeZone = timeZone + return timeFormatter.string(from: now) + } + + func updateTime(timeZone: TimeZone?) { + let now = Date() + timeFormatter.timeZone = timeZone + } + + func date(from isoString: String?) -> String? { + guard let isoString, !isoString.isEmpty, + let date = isoFormatter.date( + from: isoString) + else { + return nil } + + return dateFormatter.string(from: date) + } } diff --git a/Sources/AmtrakStatus/AmtrakStatusApp.swift b/Sources/AmtrakStatus/AmtrakStatusApp.swift index 18c1871..8e8ba38 100644 --- a/Sources/AmtrakStatus/AmtrakStatusApp.swift +++ b/Sources/AmtrakStatus/AmtrakStatusApp.swift @@ -3,9 +3,9 @@ import SwiftCrossUI @main struct AmtrakStatusApp: App { - var body: some Scene { - WindowGroup("Amtrak Status") { - ContentView() - } + var body: some Scene { + WindowGroup("AMTK Status") { + ContentView() } + } } diff --git a/Sources/AmtrakStatus/ContentView.swift b/Sources/AmtrakStatus/ContentView.swift index d182232..4d0780f 100644 --- a/Sources/AmtrakStatus/ContentView.swift +++ b/Sources/AmtrakStatus/ContentView.swift @@ -1,288 +1,293 @@ -import SwiftCrossUI import Foundation - +import SwiftCrossUI struct ContentView: View { - @State var client = AmtrakClient() + @State var client = AmtrakClient() - @State var stationCode = "ASD" // Ashland, VA - @State var rows: [TrainStatusRow] = [] - @State var isLoading = false - @State var errorMessage: String? - @State var currentStation: String = "" - @State var localTimeZone: TimeZone? - - let amtrakDate = AmtrakDateFormatting() - let unknown = "Unknown" - let staleDataThreshold: TimeInterval = 60 * 60 * 48 // 48 hours + @State var stationCode = "ASD" // Ashland, VA + @State var rows: [TrainStatusRow] = [] + @State var isLoading = false + @State var errorMessage: String? + @State var currentStation: String = "" + @State var localTimeZone: TimeZone? - var body: some View { - VStack { - HStack { - Text("Station Code:") - TextField("e.g. ASD", text: $stationCode) - .frame(width: 100) + let amtrakDate = AmtrakDateFormatting() + let unknown = "Unknown" + let staleDataThreshold: TimeInterval = 60 * 60 * 48 // 48 hours - Button("Submit") { - Task { - await loadTrainStatus(forStationCode: normalizedStationCode) - } - }.disabled(normalizedStationCode.isEmpty) - }.padding(10) + var body: some View { + VStack { + HStack { + Text("Station Code:") + TextField("e.g. ASD", text: $stationCode) + .frame(width: 100) - // Most popular Virtual Railfan stations - HStack { - Button("Ashland, VA") { - Task { - await loadTrainStatus(forStationCode: "ASD") - } - } + Button("Submit") { + Task { + await loadTrainStatus(forStationCode: normalizedStationCode) + } + }.disabled(normalizedStationCode.isEmpty) + }.padding(10) - Button("San Juan Capistrano, CA") { - Task { - await loadTrainStatus(forStationCode: "SNC") - } - } - - Button("La Plata, MO") { - Task { - await loadTrainStatus(forStationCode: "LAP") - } - } - } - - Table(rows) { - TableColumn("Time", value: \TrainStatusRow.time) - TableColumn("Number", value: \TrainStatusRow.trainNum) - TableColumn("", value: \TrainStatusRow.presence.rawValue) - TableColumn("Train", value: \TrainStatusRow.train) - TableColumn("To", value: \TrainStatusRow.to) - TableColumn("From", value: \TrainStatusRow.from) - TableColumn("Status") { - (row: TrainStatusRow) in - ZStack { - RoundedRectangle(cornerRadius: 0) - .fill(row.status.color) - Text(row.status.descrption) - .foregroundColor( - statusColour(row.status.color) - ) - } - } - TableColumn("Track", value: \TrainStatusRow.track) - }.frame(minWidth: 600) - .overlay(alignment: .bottomTrailing) { - if isLoading { - ProgressView() - .frame(alignment: .topTrailing) - .padding(10) - } - } - - HStack { - if let errorMessage { - Text(errorMessage) - } else if !currentStation.isEmpty { - Text( - "It is \(amtrakDate.localTime(timeZone: localTimeZone)) at \(currentStation) station" - ) - } - }.padding(10) + // Most popular Virtual Railfan stations + HStack { + Button("Ashland, VA") { + Task { + await loadTrainStatus(forStationCode: "ASD") + } } - .frame(idealWidth: 500) - .onAppear { - Task { - await loadTrainStatus(forStationCode: normalizedStationCode) - } - }.overlay(alignment: .topTrailing) { - Text("Information by Amtraker") - .padding(10) + + Button("San Juan Capistrano, CA") { + Task { + await loadTrainStatus(forStationCode: "SNC") + } } + + Button("La Plata, MO") { + Task { + await loadTrainStatus(forStationCode: "LAP") + } + } + } + + Table(rows) { + TableColumn("Time", value: \TrainStatusRow.time) + TableColumn("Number", value: \TrainStatusRow.trainNum) + TableColumn("", value: \TrainStatusRow.presence.rawValue) + TableColumn("Train", value: \TrainStatusRow.train) + TableColumn("To", value: \TrainStatusRow.to) + TableColumn("From", value: \TrainStatusRow.from) + TableColumn("Status") { + (row: TrainStatusRow) in + ZStack { + RoundedRectangle(cornerRadius: 0) + .fill(row.status.color) + Text(row.status.descrption) + .foregroundColor( + statusColour(row.status.color) + ) + } + } + TableColumn("Track", value: \TrainStatusRow.track) + }.frame(minWidth: 600) + .overlay(alignment: .bottomTrailing) { + if isLoading { + ProgressView() + .frame(alignment: .topTrailing) + .padding(10) + } + } + + HStack { + if let errorMessage { + Text(errorMessage) + } else if !currentStation.isEmpty { + Text( + "It is \(amtrakDate.localTime(timeZone: localTimeZone)) at \(currentStation) station" + ) + } + }.padding(10) + } + .frame(idealWidth: 500) + .onAppear { + Task { + await loadTrainStatus(forStationCode: normalizedStationCode) + } + }.overlay(alignment: .topTrailing) { + Text("Information by Amtraker") + .padding(10) + } + } + + var normalizedStationCode: String { + stationCode.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() + } + + func statusColour(_ color: Color) -> Color { + switch color { + case .green: + return .adaptive(light: .white, dark: .black) + default: + return .white + } + } + + func loadStationStatus(forStationCode code: String) async { + guard !code.isEmpty else { + return + } + } + + func loadTrainStatus(forStationCode code: String) async { + guard !code.isEmpty else { + return } - var normalizedStationCode: String { - stationCode.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() - } - - func statusColour(_ color: Color) -> Color { - switch color { - case .green: - return .adaptive(light: .white, dark: .black) - default: - return .white + isLoading = true + errorMessage = nil + + defer { isLoading = false } + + do { + let lookup = try await client.trainStationLookup( + stationCode: code, + ) + + guard !lookup.trainNumbers.isEmpty else { + errorMessage = "\(code) has no trains listed right now" + rows = [] + return + } + + var zoneCache: [String: TimeZone?] = [code: lookup.timeZone] + let staleness = await staleTrainLookup() + + var newRows: [TrainStatusRow] = [] + for number in lookup.trainNumbers { + let trains = try await client.fetchAllTrainStatus(num: number) + for train in trains { + let platform = train.stations?.first( + where: { + $0.code == code + }, + )?.platform + let row = await trainStatus( + for: train, + code: code, + zone: lookup.timeZone, + cache: &zoneCache, + staleness: staleness + ) + if let row { + newRows.append(row) + } } + } + + rows = newRows + + } catch let AmtrakError.stationNotFound(code) { + errorMessage = "Station \(code) not found" + rows = [] + } catch { + errorMessage = "Couldn't load train status: \(error.localizedDescription)" + rows = [] } - - func loadStationStatus(forStationCode code: String) async { - guard !code.isEmpty else { - return - } + } + + func trainStatus( + for train: Train, + code stationCode: String, + zone timeZone: TimeZone?, + cache zoneCache: inout [String: TimeZone?], + staleness: [String: TimeInterval] + ) async -> TrainStatusRow? { + let leg = train.stations?.first { $0.code == stationCode } + + guard + !isStale(leg, timeZone: timeZone), !isDataStale(train.trainID, staleness: staleness) + else { + return nil } - func loadTrainStatus(forStationCode code: String) async { - guard !code.isEmpty else { - return - } + let stationName = leg?.name ?? unknown + let track = leg?.platform ?? "" - isLoading = true - errorMessage = nil - - defer { isLoading = false } - - do { - let lookup = try await client.trainStationLookup( - stationCode: code, - ) - - guard !lookup.trainNumbers.isEmpty else { - errorMessage = "\(code) has no trains listed right now" - rows = [] - return - } - - var zoneCache: [String: TimeZone?] = [code: lookup.timeZone] - let staleness = await staleTrainLookup() - - var newRows: [TrainStatusRow] = [] - for number in lookup.trainNumbers { - let trains = try await client.fetchAllTrainStatus(num: number) - for train in trains { - let platform = train.stations?.first( - where: { $0.code == code - }, - )?.platform - let row = await trainStatus( - for: train, - code: code, - zone: lookup.timeZone, - cache: &zoneCache, - staleness: staleness - ) - if let row { - newRows.append(row) - } - } - } - - rows = newRows - - } catch let AmtrakError.stationNotFound(code) { - errorMessage = "Station \(code) not found" - rows = [] - } catch { - errorMessage = "Couldn't load train status: \(error.localizedDescription)" - rows = [] - } + var originZone: TimeZone? + if let orgCode = train.origCode { + originZone = await fetchTimeZone( + forStationCode: orgCode, + cache: &zoneCache + ) } - - func trainStatus( - for train: Train, - code stationCode: String, - zone timeZone: TimeZone?, - cache zoneCache: inout [String: TimeZone?], - staleness: [String: TimeInterval] - ) async -> TrainStatusRow? { - let leg = train.stations?.first { $0.code == stationCode } - - guard - !isStale(leg, timeZone: timeZone), !isDataStale(train.trainID, staleness: staleness) - else { - return nil - } - - let stationName = leg?.name ?? unknown - let track = leg?.platform ?? "" - - var originZone: TimeZone? - if let orgCode = train.origCode { - originZone = await fetchTimeZone( - forStationCode: orgCode, - cache: &zoneCache - ) - } - - let time = amtrakDate.time( - from: leg?.arr, - in: timeZone, - comparedTo: originZone - ) ?? amtrakDate.time( - from: leg?.schArr, - in: timeZone, - comparedTo: originZone - ) - - if stationName != unknown { - currentStation = stationName - localTimeZone = timeZone - } - - return TrainStatusRow( - trainID: train.trainID, - time: time ?? "", - trainNum: train.trainNum, - presence: leg?.status ?? .unknown, // Yeah... It's a little confusing - train: train.routeName, - to: train.destName, - from: train.origName, - status: amtrakDate.isOnTime( - actual: leg?.arr, - scheduled: leg?.schArr - ), - track: track.isEmpty ? "" : track - ) + + let time = + amtrakDate.time( + from: leg?.arr, + in: timeZone, + comparedTo: originZone + ) + ?? amtrakDate.time( + from: leg?.schArr, + in: timeZone, + comparedTo: originZone + ) + + if stationName != unknown { + currentStation = stationName + localTimeZone = timeZone } - - func isStale(_ leg: Station?, timeZone: TimeZone?) -> Bool { - guard leg?.status == .departed else { return false } - - guard - let deparatureDate = amtrakDate.parsedDate( - from: leg?.dep - ) ?? amtrakDate.parsedDate(from: leg?.schDep) - else { - return false - } - - var calendar = Calendar.current - calendar.timeZone = timeZone ?? .current - return calendar - .compare( - deparatureDate, - to: Date(), - toGranularity: .day - ) == .orderedAscending + + return TrainStatusRow( + trainID: train.trainID, + time: time ?? "", + trainNum: train.trainNum, + presence: leg?.status ?? .unknown, // Yeah... It's a little confusing + train: train.routeName, + to: train.destName, + from: train.origName, + status: amtrakDate.isOnTime( + actual: leg?.arr, + scheduled: leg?.schArr + ), + track: track.isEmpty ? "" : track + ) + } + + func isStale(_ leg: Station?, timeZone: TimeZone?) -> Bool { + guard leg?.status == .departed else { return false } + + guard + let deparatureDate = amtrakDate.parsedDate( + from: leg?.dep + ) ?? amtrakDate.parsedDate(from: leg?.schDep) + else { + return false } - - func isDataStale( - _ trainId: String, - staleness: [String: TimeInterval] - ) -> Bool { - guard let timeSince = staleness[trainId] else { return false } - return timeSince > staleDataThreshold + + var calendar = Calendar.current + calendar.timeZone = timeZone ?? .current + return + calendar + .compare( + deparatureDate, + to: Date(), + toGranularity: .day + ) == .orderedAscending + } + + func isDataStale( + _ trainId: String, + staleness: [String: TimeInterval] + ) -> Bool { + guard let timeSince = staleness[trainId] else { return false } + return timeSince > staleDataThreshold + } + + func staleTrainLookup() async -> [String: TimeInterval] { + guard let staleData = try? await client.fetchStaleStatus() else { + return [:] } - - func staleTrainLookup() async -> [String: TimeInterval] { - guard let staleData = try? await client.fetchStaleStatus() else { - return [:] - } - return Dictionary( - staleData.lastUpdatedArr.map { ( $0.trainID, $0.timeSince) }, - uniquingKeysWith: { first, _ in first } - ) + return Dictionary( + staleData.lastUpdatedArr.map { ($0.trainID, $0.timeSince) }, + uniquingKeysWith: { first, _ in first } + ) + } + + func fetchTimeZone(forStationCode code: String, cache: inout [String: TimeZone?]) async + -> TimeZone? + { + if let cached = cache[code] { + return cached } - - func fetchTimeZone(forStationCode code: String, cache: inout [String: TimeZone?]) async -> TimeZone? { - if let cached = cache[code] { - return cached - } - let zone: TimeZone? - do { - let response = try await client.fetchStation(code: code) - zone = response[code]?.tz.flatMap(TimeZone.init(identifier:)) - } catch { - zone = nil - } - - return zone + let zone: TimeZone? + do { + let response = try await client.fetchStation(code: code) + zone = response[code]?.tz.flatMap(TimeZone.init(identifier:)) + } catch { + zone = nil } + + return zone + } } diff --git a/Sources/AmtrakStatus/Helper.swift b/Sources/AmtrakStatus/Helper.swift index 7ad152e..97d62cd 100644 --- a/Sources/AmtrakStatus/Helper.swift +++ b/Sources/AmtrakStatus/Helper.swift @@ -1,7 +1,7 @@ import Foundation extension Int { - func pluralized(singular: String, plural: String) -> String { - self == 1 ? singular : plural - } + func pluralized(singular: String, plural: String) -> String { + self == 1 ? singular : plural + } } diff --git a/Sources/AmtrakStatus/LateTrain.swift b/Sources/AmtrakStatus/LateTrain.swift index c36ac7b..6f00bd8 100644 --- a/Sources/AmtrakStatus/LateTrain.swift +++ b/Sources/AmtrakStatus/LateTrain.swift @@ -2,48 +2,48 @@ import Foundation import SwiftCrossUI enum LateTrain { - case onTime - case early(minutes: Int) - case late(minutes: Int) - case unknown - - var descrption: String { - switch self { - case .onTime: - return "On Time" - case .early(minutes: let minutes): - return "\(Self.formatted(minutes)) early" - case .late(minutes: let minutes): - return "\(Self.formatted(minutes)) late" - case .unknown: - return "" - } + case onTime + case early(minutes: Int) + case late(minutes: Int) + case unknown + + var descrption: String { + switch self { + case .onTime: + return "On Time" + case .early(let minutes): + return "\(Self.formatted(minutes)) early" + case .late(let minutes): + return "\(Self.formatted(minutes)) late" + case .unknown: + return "" } - - var color: Color { - switch self { - case .onTime: - return .green - case .early: - return .blue - case .late: - return .red - case .unknown: - return .gray - } + } + + var color: Color { + switch self { + case .onTime: + return .green + case .early: + return .blue + case .late: + return .red + case .unknown: + return .gray } - - static func formatted(_ minutes: Int) -> String { - let hours = minutes / 60 - let remainder = minutes % 60 - - let minutesPart = "\(minutes) \(minutes.pluralized(singular: "min", plural: "mins"))" - let hoursPart = "\(hours) \(hours.pluralized(singular: "hr", plural: "hrs"))" - - guard hours > 0 else { - return minutesPart - } - - return hoursPart + } + + static func formatted(_ minutes: Int) -> String { + let hours = minutes / 60 + let remainder = minutes % 60 + + let minutesPart = "\(minutes) \(minutes.pluralized(singular: "min", plural: "mins"))" + let hoursPart = "\(hours) \(hours.pluralized(singular: "hr", plural: "hrs"))" + + guard hours > 0 else { + return minutesPart } + + return hoursPart + } } diff --git a/Sources/AmtrakStatus/Models.swift b/Sources/AmtrakStatus/Models.swift index 11d5aec..4ca0332 100644 --- a/Sources/AmtrakStatus/Models.swift +++ b/Sources/AmtrakStatus/Models.swift @@ -1,110 +1,110 @@ import Foundation struct TrainAlert: Codable { - var message: String + var message: String } struct Station: Codable, Sendable { - var name: String - var code: String - var tz: String - var bus: Bool - var schArr: String - var schDep: String - var arr: String - var dep: String - var arrCmnt: String - var depCmnt: String - var status: StationStatus - var stopIconColor: String - var platform: String + var name: String + var code: String + var tz: String + var bus: Bool + var schArr: String + var schDep: String + var arr: String + var dep: String + var arrCmnt: String + var depCmnt: String + var status: StationStatus + var stopIconColor: String + var platform: String } enum StationStatus: String, Codable, Sendable { - case enroute = "Enroute" - case station = "Station" - case departed = "Departed" - case unknown = "Unknown" - - init(from decoder: any Decoder) throws { - let container = try decoder.singleValueContainer() - let rawValue = try container.decode(String.self) - self = StationStatus(rawValue: rawValue) ?? .unknown - } + case enroute = "Enroute" + case station = "Station" + case departed = "Departed" + case unknown = "Unknown" + + init(from decoder: any Decoder) throws { + let container = try decoder.singleValueContainer() + let rawValue = try container.decode(String.self) + self = StationStatus(rawValue: rawValue) ?? .unknown + } } struct Train: Codable { - var routeName: String - var trainNum: String - var trainNumRaw: String? - var trainID: String - var lat: Double? - var lon: Double? - var trainTimely: String? - var iconColor: String? - var textColor: String? - var heading: String? - var eventCode: String? - var eventTZ: String? - var eventName: String? - var origCode: String? - var origName: String - var destCode: String? - var destName: String - var trainState: String? - var velocity: Double? - var statusMsg: String? - var createdAt: String? - var updatedAt: String? - var lastValTS: String? - var objectID: Int? - var provider: String? - var providerShort: String? - var onlyOfTrainNum: Bool? - var alerts: [TrainAlert]? - var stations: [Station]? + var routeName: String + var trainNum: String + var trainNumRaw: String? + var trainID: String + var lat: Double? + var lon: Double? + var trainTimely: String? + var iconColor: String? + var textColor: String? + var heading: String? + var eventCode: String? + var eventTZ: String? + var eventName: String? + var origCode: String? + var origName: String + var destCode: String? + var destName: String + var trainState: String? + var velocity: Double? + var statusMsg: String? + var createdAt: String? + var updatedAt: String? + var lastValTS: String? + var objectID: Int? + var provider: String? + var providerShort: String? + var onlyOfTrainNum: Bool? + var alerts: [TrainAlert]? + var stations: [Station]? } struct StationMeta: Codable { - var name: String - var code: String - var tz: String? - var lat: Double? - var lon: Double? - var hasAddress: Bool? - var address1: String? - var address2: String? - var city: String? - var state: String? - var zip: String? - var trains: [String] + var name: String + var code: String + var tz: String? + var lat: Double? + var lon: Double? + var hasAddress: Bool? + var address1: String? + var address2: String? + var city: String? + var state: String? + var zip: String? + var trains: [String] } struct StaleData: Codable { - var activeTrains: Int - var avgLastUpdate: Double - var lastUpdatedArr: [TrainFreshness] - var medianLastUpdate: Double + var activeTrains: Int + var avgLastUpdate: Double + var lastUpdatedArr: [TrainFreshness] + var medianLastUpdate: Double } struct TrainFreshness: Codable, Sendable { - var trainID: String - var timeSince: Double + var trainID: String + var timeSince: Double } typealias TrainResponse = [String: [Train]] typealias StationResponse = [String: StationMeta] struct TrainStatusRow: Identifiable { - var id: String { trainID } - - var trainID: String - var time: String - var trainNum: String - var presence: StationStatus - var train: String - var to: String - var from: String - var status: LateTrain - var track: String + var id: String { trainID } + + var trainID: String + var time: String + var trainNum: String + var presence: StationStatus + var train: String + var to: String + var from: String + var status: LateTrain + var track: String } diff --git a/format.sh b/format.sh new file mode 100755 index 0000000..2653b15 --- /dev/null +++ b/format.sh @@ -0,0 +1,17 @@ +#!/bin/bash +##===----------------------------------------------------------------------===## +## +## This source file is from the Swift Argument Parser open source project +## +## Copyright (c) 2025 Apple Inc. and the Swift project authors +## Licensed under Apache License v2.0 with Runtime Library Exception +## +## See https://swift.org/LICENSE.txt for license information +## +##===----------------------------------------------------------------------===## + +echo "Formatting Swift sources in $(pwd)" + +# Run the format / lint commands +git ls-files -z '*.swift' | xargs -0 swift format format --parallel --in-place +git ls-files -z '*.swift' | xargs -0 swift format lint --strict --parallel