diff --git a/Bundler.toml b/Bundler.toml
index a42c273..cc33fa4 100644
--- a/Bundler.toml
+++ b/Bundler.toml
@@ -1,9 +1,9 @@
format_version = 2
[apps.AmtrakStatus]
-identifier = 'com.example.AmtrakStatus'
+identifier = 'com.tonybark.AmtrakStatus'
product = 'AmtrakStatus'
-version = '0.1.0'
+version = '0.2.102'
icon = "./assets/icon.png"
diff --git a/README.md b/README.md
index 41fc07d..b132b6e 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,6 @@
-
+
+
## 🖥️ Supported Platforms
@@ -7,7 +8,7 @@
| Platform | Minimum Version |
| ---------------------------- | --------------- |
| macOS | 10.15+ |
-| Windows | 10+ |
+| Windows | 11+ |
| Linux | gtk 3+ |
## 🔨 Setup
@@ -35,9 +36,9 @@ swift-bundler bundle -c (release|debug)
This was intended to solve a rather awkward dilemma. I turned a bunch of online Amtrak status boards made by [Dixieland Software](http://dixielandsoftware.net/Amtrak/status/StatusMaps/) into PWAs (standalone web apps) for stations that I viewed on Virtual Railfan. While it was convenient - don't get me wrong - I had to create a new web app for each and every station, and it was becoming overwhelming.
-So I created simple frontend that mapped to Dixieland Software's API using Tauri. You could type in simple station code and it'd take you their status board from within the app. Simple, right? Unfortunately, I didn't know how to get it go back to change the station, my attempted UI redesigns kept failing, and I accidentally botched a migration to Tauri 2.0.
+So I created simple frontend that mapped to Dixieland Software's API using Tauri. You could type in simple station code and it'd take you their status board from within the app. Simple, right? Unfortunately, I didn't know how to get it go back to change the station, my attempted UI redesigns kept failing, and I accidentally botched a migration to Tauri 2.0. I'm just not that good with Node.
-So it sat abandoned until I discovered SwiftCrossUI. Now it directly makes use of [Amtraker](https://amtraker.com/)'s API.
+So it sat abandoned until I discovered [SwiftCrossUI](https://github.com/moreSwift/swift-cross-ui). Now it directly makes use of [Amtraker](https://amtraker.com/)'s API.
## ⚖️ License
diff --git a/Sources/AmtrakStatus/AmtrakClient.swift b/Sources/AmtrakStatus/AmtrakClient.swift
index 3430066..0fbe23f 100644
--- a/Sources/AmtrakStatus/AmtrakClient.swift
+++ b/Sources/AmtrakStatus/AmtrakClient.swift
@@ -7,6 +7,12 @@ enum AmtrakError: Error {
case stationNotFound(String)
}
+
+struct TrainStationLookUp: Sendable {
+ var trainNumbers: [String]
+ var timeZone: TimeZone?
+}
+
struct AmtrakClient {
var decoder = JSONDecoder()
@@ -41,15 +47,21 @@ struct AmtrakClient {
.first
.map(String.init) ?? trainId
}
+
- func fetchStationTrainNumbers(stationCode code: String) async throws -> [String] {
+ 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)
}
-
- return Array(Set(meta.trains.map(trainNumber(fromTrainId:))))
+
+ 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 {
diff --git a/Sources/AmtrakStatus/AmtrakDateFormatting.swift b/Sources/AmtrakStatus/AmtrakDateFormatting.swift
index 912c9c7..d1f4fdd 100644
--- a/Sources/AmtrakStatus/AmtrakDateFormatting.swift
+++ b/Sources/AmtrakStatus/AmtrakDateFormatting.swift
@@ -1,32 +1,52 @@
import Foundation
struct AmtrakDateFormatting {
- static let isoFormatter = ISO8601DateFormatter()
+ let isoFormatter = ISO8601DateFormatter()
- static let timeFormatter: DateFormatter = {
+ let timeFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateStyle = .none
formatter.timeStyle = .short
return formatter
}()
- static let dateFormatter: DateFormatter = {
+ let dateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateStyle = .medium
formatter.timeStyle = .none
return formatter
}()
- static func time(from isoString: String?) -> String? {
+ 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 time(from isoString: String?, timeZone: TimeZone? = nil) -> String? {
guard let isoString, !isoString.isEmpty, let date = isoFormatter.date(
from: isoString) else {
return nil
}
-
+ timeFormatter.timeZone = timeZone
return timeFormatter.string(from: date)
}
- static func date(from isoString: String?) -> String? {
+ 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
diff --git a/Sources/AmtrakStatus/ContentView.swift b/Sources/AmtrakStatus/ContentView.swift
index 316a852..09d601c 100644
--- a/Sources/AmtrakStatus/ContentView.swift
+++ b/Sources/AmtrakStatus/ContentView.swift
@@ -1,11 +1,6 @@
import SwiftCrossUI
import Foundation
-extension Train {
- var serviceDate: String {
- AmtrakDateFormatting.date(from: createdAt) ?? "-"
- }
-}
struct ContentView: View {
@State var client = AmtrakClient()
@@ -15,7 +10,9 @@ struct ContentView: View {
@State var isLoading = false
@State var errorMessage: String?
@State var currentStation: String = ""
+ @State var localTimeZone: TimeZone?
+ let amtrakDate = AmtrakDateFormatting()
let unknown = "Unknown"
var body: some View {
@@ -25,11 +22,11 @@ struct ContentView: View {
TextField("e.g. ASD", text: $stationCode)
.frame(width: 100)
- Button("Refresh") {
+ Button("Submit") {
Task {
await loadTrainStatus(forStationCode: normalizedStationCode)
}
- }.disabled(normalizedStationCode.isEmpty || isLoading)
+ }.disabled(normalizedStationCode.isEmpty)
}.padding(10)
// Most popular Virtual Railfan stations
@@ -57,12 +54,22 @@ struct ContentView: View {
TableColumn("Time", value: \TrainStatusRow.arrival)
TableColumn("Number", value: \TrainStatusRow.trainNum)
TableColumn("Train", value: \TrainStatusRow.routeName)
- TableColumn("To", value: \TrainStatusRow.origin)
- TableColumn("From", value: \TrainStatusRow.destination)
- TableColumn("Status", value: \TrainStatusRow.status)
+ TableColumn("To", value: \TrainStatusRow.destination)
+ TableColumn("From", value: \TrainStatusRow.origin)
+ 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.platform)
-
- }.overlay(alignment: .bottomTrailing) {
+ }.frame(minWidth: 600)
+ .overlay(alignment: .bottomTrailing) {
if isLoading {
ProgressView()
.frame(alignment: .topTrailing)
@@ -74,7 +81,9 @@ struct ContentView: View {
if let errorMessage {
Text(errorMessage)
} else if !currentStation.isEmpty {
- Text(currentStation)
+ Text(
+ "It is \(amtrakDate.localTime(timeZone: localTimeZone)) at \(currentStation) station"
+ )
}
}.padding(10)
}
@@ -89,6 +98,21 @@ struct ContentView: View {
var normalizedStationCode: String {
stationCode.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
}
+
+ func statusColour(_ color: Color) -> Color {
+ switch color {
+ case .green:
+ return .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 {
@@ -101,25 +125,32 @@ struct ContentView: View {
defer { isLoading = false }
do {
- let trainNumbers = try await client.fetchStationTrainNumbers(
+ let lookup = try await client.trainStationLookup(
stationCode: code,
)
- guard !trainNumbers.isEmpty else {
+ guard !lookup.trainNumbers.isEmpty else {
errorMessage = "\(code) has no trains listed right now"
rows = []
return
}
var newRows: [TrainStatusRow] = []
- for number in trainNumbers {
+ 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
- newRows.append(trainStatus(for: train, atStationCode: code))
+ newRows
+ .append(
+ trainStatus(
+ for: train,
+ atStationCode: code,
+ timeZone: lookup.timeZone
+ )
+ )
}
}
@@ -134,30 +165,39 @@ struct ContentView: View {
}
}
- func isOnTime(arr: String?, schArr: String?) -> String {
- let amtrakDate = AmtrakDateFormatting()
-
- if AmtrakDateFormatting
- .time(from: arr) != AmtrakDateFormatting
- .time(from: schArr) {
- return "Late"
+ func isOnTime(arr: String?, schArr: String?) -> TrainLateness {
+
+ guard
+ let actualDate = amtrakDate.parsedDate(from: arr),
+ let scheduledDate = amtrakDate.parsedDate(from: schArr)
+ else {
+ return .unknown
}
- return "On Time"
+ 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 trainStatus(for train: Train, atStationCode code: String) -> TrainStatusRow {
+ func trainStatus(for train: Train, atStationCode code: String, timeZone: TimeZone?) -> TrainStatusRow {
let leg = train.stations?.first { $0.code == code }
- let platform = leg?.platform ?? "-"
let stationName = leg?.name ?? unknown
- let arrival = AmtrakDateFormatting.time(from: leg?.arr) ?? AmtrakDateFormatting.time(
- from: leg?.schArr
- )
- let depature = AmtrakDateFormatting.time(from: leg?.dep) ?? AmtrakDateFormatting.time(
- from: leg?.schDep
- )
+ let arrival = amtrakDate.time(from: leg?.arr, timeZone: timeZone) ?? amtrakDate.time(
+ from: leg?.schArr,
+ timeZone: timeZone)
+ let depature = amtrakDate.time(from: leg?.dep, timeZone: timeZone) ?? amtrakDate.time(
+ from: leg?.schDep,
+ timeZone: timeZone)
- currentStation = "\(stationName) station"
+ currentStation = stationName
+ localTimeZone = timeZone
return TrainStatusRow(
trainID: train.trainID,
diff --git a/Sources/AmtrakStatus/Models.swift b/Sources/AmtrakStatus/Models.swift
index 401b91c..eba059c 100644
--- a/Sources/AmtrakStatus/Models.swift
+++ b/Sources/AmtrakStatus/Models.swift
@@ -97,7 +97,7 @@ struct TrainStatusRow: Identifiable {
var trainID: String
var trainNum: String
var routeName: String
- var status: String
+ var status: TrainLateness
var platform: String
var arrival: String
var departure: String
diff --git a/Sources/AmtrakStatus/TrainLateness.swift b/Sources/AmtrakStatus/TrainLateness.swift
new file mode 100644
index 0000000..cd59dd1
--- /dev/null
+++ b/Sources/AmtrakStatus/TrainLateness.swift
@@ -0,0 +1,45 @@
+import Foundation
+import SwiftCrossUI
+
+enum TrainLateness {
+ 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 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
+ guard hours > 0 else {
+ return "\(minutes) mins"
+ }
+ return "\(hours) hrs"
+ }
+}
diff --git a/assets/screenshot.png b/assets/screenshot.png
new file mode 100644
index 0000000..e293d5b
Binary files /dev/null and b/assets/screenshot.png differ
diff --git a/changelog.md b/changelog.md
new file mode 100644
index 0000000..8a28089
--- /dev/null
+++ b/changelog.md
@@ -0,0 +1,23 @@
+# Change Log
+
+## 0.2.102
+
+- The status bar at the bottom now shows ``It is