mirror of
https://github.com/tonytins/amtkstat.git
synced 2026-10-24 05:32:54 -04:00
Formatted code
Brought over and applied Swift Argument Parser's code formatting script
This commit is contained in:
parent
dc464de1c4
commit
c699007cbf
9 changed files with 593 additions and 563 deletions
|
|
@ -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"),
|
||||
],
|
||||
)
|
||||
],
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
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<T: Decodable>(_ urlString: String) async throws -> T {
|
||||
guard let url = URL(string: urlString) else {
|
||||
throw AmtrakError.invalidURL(urlString)
|
||||
}
|
||||
|
||||
func fetchStaleStatus() async throws -> StaleData {
|
||||
try await get(staleBaseURL)
|
||||
let (data, reponse) = try await URLSession.shared.data(from: url)
|
||||
guard let http = reponse as? HTTPURLResponse else {
|
||||
throw AmtrakError.invalidResponse
|
||||
}
|
||||
|
||||
func fetchAllTrainStatus(num trainNum: String) async throws -> [Train] {
|
||||
let reponse = try await fetchTrain(number: trainNum)
|
||||
return reponse.values.flatMap(\.self)
|
||||
guard (200..<300).contains(http.statusCode) else {
|
||||
throw AmtrakError.httpStatus(http.statusCode)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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<T: Decodable>(_ 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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,93 +1,99 @@
|
|||
import Foundation
|
||||
|
||||
struct AmtrakDateFormatting {
|
||||
let isoFormatter = ISO8601DateFormatter()
|
||||
let isoFormatter = ISO8601DateFormatter()
|
||||
|
||||
let timeFormatter: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateStyle = .none
|
||||
formatter.timeStyle = .short
|
||||
return formatter
|
||||
}()
|
||||
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
|
||||
}()
|
||||
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)
|
||||
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
|
||||
}
|
||||
return isoFormatter.date(from: isoString)
|
||||
}
|
||||
|
||||
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)"
|
||||
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
|
||||
|
||||
func isOnTime(actual: String?, scheduled: String?) -> LateTrain {
|
||||
let formatted = timeFormatter.string(from: date)
|
||||
|
||||
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)
|
||||
}
|
||||
guard
|
||||
let zone,
|
||||
let originZone,
|
||||
zone.identifier != originZone.identifier,
|
||||
let abbreviation = zone.abbreviation(for: date)
|
||||
else {
|
||||
return formatted
|
||||
}
|
||||
|
||||
return "\(formatted) \(abbreviation)"
|
||||
}
|
||||
|
||||
func localTime(timeZone: TimeZone?) -> String {
|
||||
let now = Date()
|
||||
timeFormatter.timeZone = timeZone
|
||||
return timeFormatter.string(from: now)
|
||||
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 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
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
return dateFormatter.string(from: date)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,288 +1,297 @@
|
|||
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?
|
||||
@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
|
||||
let amtrakDate = AmtrakDateFormatting()
|
||||
let unknown = "Unknown"
|
||||
let staleDataThreshold: TimeInterval = 60 * 60 * 48 // 48 hours
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
HStack {
|
||||
Text("Station Code:")
|
||||
TextField("e.g. ASD", text: $stationCode)
|
||||
.frame(width: 100)
|
||||
|
||||
Button("Submit") {
|
||||
Task {
|
||||
await loadTrainStatus(forStationCode: normalizedStationCode)
|
||||
}
|
||||
}.disabled(normalizedStationCode.isEmpty)
|
||||
}.padding(10)
|
||||
|
||||
// Most popular Virtual Railfan stations
|
||||
HStack {
|
||||
Button("Ashland, VA") {
|
||||
Task {
|
||||
await loadTrainStatus(forStationCode: "ASD")
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
var body: some View {
|
||||
VStack {
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
}.padding(10)
|
||||
|
||||
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 && !isLoading {
|
||||
Text(
|
||||
"It is \(amtrakDate.localTime(timeZone: localTimeZone)) at \(currentStation) station"
|
||||
)
|
||||
}
|
||||
}.padding(10)
|
||||
}
|
||||
.frame(idealWidth: 500)
|
||||
.onAppear {
|
||||
Task {
|
||||
await loadTrainStatus(forStationCode: normalizedStationCode)
|
||||
}
|
||||
}.overlay(alignment: .topTrailing) {
|
||||
HStack {
|
||||
Text("Station Code:")
|
||||
TextField("e.g. ASD", text: $stationCode)
|
||||
.frame(width: 100)
|
||||
|
||||
Button("Submit") {
|
||||
Task {
|
||||
await loadTrainStatus(forStationCode: normalizedStationCode)
|
||||
}
|
||||
}.disabled(normalizedStationCode.isEmpty)
|
||||
}.frame(alignment: .topTrailing)
|
||||
.padding(10)
|
||||
|
||||
}
|
||||
.overlay(alignment: .topLeading) {
|
||||
Text("Information by Amtraker")
|
||||
.frame(alignment: .topLeading)
|
||||
.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()
|
||||
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 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 statusColour(_ color: Color) -> Color {
|
||||
switch color {
|
||||
case .green:
|
||||
return .adaptive(light: .white, dark: .black)
|
||||
default:
|
||||
return .white
|
||||
}
|
||||
let stationName = leg?.name ?? unknown
|
||||
let track = leg?.platform ?? ""
|
||||
|
||||
var originZone: TimeZone?
|
||||
if let orgCode = train.origCode {
|
||||
originZone = await fetchTimeZone(
|
||||
forStationCode: orgCode,
|
||||
cache: &zoneCache
|
||||
)
|
||||
}
|
||||
|
||||
func loadStationStatus(forStationCode code: String) async {
|
||||
guard !code.isEmpty else {
|
||||
return
|
||||
}
|
||||
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 loadTrainStatus(forStationCode code: String) async {
|
||||
guard !code.isEmpty else {
|
||||
return
|
||||
}
|
||||
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
|
||||
)
|
||||
}
|
||||
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
func isStale(_ leg: Station?, timeZone: TimeZone?) -> Bool {
|
||||
guard leg?.status == .departed else { return false }
|
||||
|
||||
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 = []
|
||||
}
|
||||
guard
|
||||
let deparatureDate = amtrakDate.parsedDate(
|
||||
from: leg?.dep
|
||||
) ?? amtrakDate.parsedDate(from: leg?.schDep)
|
||||
else {
|
||||
return false
|
||||
}
|
||||
|
||||
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 }
|
||||
var calendar = Calendar.current
|
||||
calendar.timeZone = timeZone ?? .current
|
||||
return
|
||||
calendar
|
||||
.compare(
|
||||
deparatureDate,
|
||||
to: Date(),
|
||||
toGranularity: .day
|
||||
) == .orderedAscending
|
||||
}
|
||||
|
||||
guard
|
||||
!isStale(leg, timeZone: timeZone), !isDataStale(train.trainID, staleness: staleness)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
func isDataStale(
|
||||
_ trainId: String,
|
||||
staleness: [String: TimeInterval]
|
||||
) -> Bool {
|
||||
guard let timeSince = staleness[trainId] else { return false }
|
||||
return timeSince > staleDataThreshold
|
||||
}
|
||||
|
||||
let stationName = leg?.name ?? unknown
|
||||
let track = leg?.platform ?? ""
|
||||
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 }
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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 [:]
|
||||
}
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
return zone
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,48 +2,48 @@ import Foundation
|
|||
import SwiftCrossUI
|
||||
|
||||
enum LateTrain {
|
||||
case onTime
|
||||
case early(minutes: Int)
|
||||
case late(minutes: Int)
|
||||
case unknown
|
||||
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 ""
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
return hoursPart
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
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
|
||||
}
|
||||
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 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 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
|
||||
}
|
||||
|
|
|
|||
17
format.sh
Executable file
17
format.sh
Executable file
|
|
@ -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
|
||||
Loading…
Add table
Add a link
Reference in a new issue