Compare commits

...

13 commits

Author SHA1 Message Date
0543d43ba5 Bumped the app version to 0.2.102 2026-08-24 03:13:04 -04:00
a43eefadc1 Show local station time; reuse date formatter
- Added localTime(timeZone:) and updateTime(timeZone:) to AmtrakDateFormatting to format the current time for a given timezone.
- In ContentView, make AmtrakDateFormatting is now a shared property with localTimeZone being used at the bottom to show "It is <time> at <station> station".
- trainStatus now saves the station name and timezone (avoiding repeated formatter instantiation) and isOnTime uses the shared formatter.
2026-08-24 03:09:49 -04:00
791f261fc7 Brought back current station 2026-08-24 01:24:50 -04:00
e6f180ae2d Brought back changelog
Updated the app bundle identifier and version for the 0.2.101 release, refreshed the README link to SwiftCrossUI, and added a changelog documenting the rewrite and previous release history.
2026-08-24 01:16:32 -04:00
dd50d7cd4a Add timezone-aware station lookup and formatting
These changes ensure displayed times respect the station's timezone. Something I mistakenly overlooked when creating this.

Luckily, my screenshot with Ashland isn't affected by this change.
2026-08-24 01:00:47 -04:00
fc45303761 Bumped up the Windows version requirement, for now
Admittedly, I don't know. Haven't checked how low Swift can go in a hot minute.
Just gonna assume Swift targets the latest and greatest version of Windows because Microsoft says so.
2026-08-23 22:19:01 -04:00
4ae8be2e69 Fixed a slight oversight in TrainLateness's formatter 2026-08-23 21:59:03 -04:00
eba5f86405 Say cheese 2026-08-23 21:54:58 -04:00
921e23424f Woops! Fixed to and from's values 2026-08-23 21:52:08 -04:00
c74e6166bf Fix status text contrast on green rows
Green status rows used white text, which was difficult to read against the green background. This change selects a contrasting text color based on the status color so green rows use black text and other statuses keep white text for better legibility in the status table.
2026-08-23 21:46:15 -04:00
915d0ec2dc Train status is now coloured coded for your convenience
- Introduced TrainLateness enum to represent on-time, early, late (with minutes), and unknown states, including description text, color, and formatting logic.
- Replaced TrainStatusRow.status String with TrainLateness and update ContentView to render a colored status pill and to return TrainLateness from isOnTime.
2026-08-23 21:23:45 -04:00
3533e5d4b8 Train status now properly gives minutes (or hours) a train is early or late
- Convert AmtrakDateFormatting API from static to instance properties/methods and added parsedDate(from:) to return Date.
- Update time(from:) and date(from:) to instance methods. Improve isOnTime logic to compute actual vs scheduled differences (minutes/hours) and report early/late/on-time.
- Updated ContentView to create an AmtrakDateFormatting instance and use the new API. Remove the Train.serviceDate extension.
2026-08-23 20:32:33 -04:00
69fc10c6f2 Renamed Refresh button to Submit 2026-08-23 20:02:10 -04:00
9 changed files with 191 additions and 50 deletions

View file

@ -1,9 +1,9 @@
format_version = 2 format_version = 2
[apps.AmtrakStatus] [apps.AmtrakStatus]
identifier = 'com.example.AmtrakStatus' identifier = 'com.tonybark.AmtrakStatus'
product = 'AmtrakStatus' product = 'AmtrakStatus'
version = '0.1.0' version = '0.2.102'
icon = "./assets/icon.png" icon = "./assets/icon.png"

View file

@ -1,5 +1,6 @@
<p align="center"> <p align="center">
<img title="" src="assets/banner.svg" alt="" data-align="center" width="718"> <img title="" src="assets/banner.svg" alt="" data-align="center" width="80%">
<img title="" src="assets/screenshot.png" alt="" data-align="center" width="70%">
</p> </p>
## 🖥️ Supported Platforms ## 🖥️ Supported Platforms
@ -7,7 +8,7 @@
| Platform | Minimum Version | | Platform | Minimum Version |
| ---------------------------- | --------------- | | ---------------------------- | --------------- |
| macOS | 10.15+ | | macOS | 10.15+ |
| Windows | 10+ | | Windows | 11+ |
| Linux | gtk 3+ | | Linux | gtk 3+ |
## 🔨 Setup ## 🔨 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. 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 ## ⚖️ License

View file

@ -7,6 +7,12 @@ enum AmtrakError: Error {
case stationNotFound(String) case stationNotFound(String)
} }
struct TrainStationLookUp: Sendable {
var trainNumbers: [String]
var timeZone: TimeZone?
}
struct AmtrakClient { struct AmtrakClient {
var decoder = JSONDecoder() var decoder = JSONDecoder()
@ -41,15 +47,21 @@ struct AmtrakClient {
.first .first
.map(String.init) ?? trainId .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) let reponse = try await fetchStation(code: code)
guard let meta = reponse[code] else { guard let meta = reponse[code] else {
throw AmtrakError.stationNotFound(code) 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<T: Decodable>(_ urlString: String) async throws -> T { func get<T: Decodable>(_ urlString: String) async throws -> T {

View file

@ -1,32 +1,52 @@
import Foundation import Foundation
struct AmtrakDateFormatting { struct AmtrakDateFormatting {
static let isoFormatter = ISO8601DateFormatter() let isoFormatter = ISO8601DateFormatter()
static let timeFormatter: DateFormatter = { let timeFormatter: DateFormatter = {
let formatter = DateFormatter() let formatter = DateFormatter()
formatter.dateStyle = .none formatter.dateStyle = .none
formatter.timeStyle = .short formatter.timeStyle = .short
return formatter return formatter
}() }()
static let dateFormatter: DateFormatter = { let dateFormatter: DateFormatter = {
let formatter = DateFormatter() let formatter = DateFormatter()
formatter.dateStyle = .medium formatter.dateStyle = .medium
formatter.timeStyle = .none formatter.timeStyle = .none
return formatter 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( guard let isoString, !isoString.isEmpty, let date = isoFormatter.date(
from: isoString) else { from: isoString) else {
return nil return nil
} }
timeFormatter.timeZone = timeZone
return timeFormatter.string(from: date) 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( guard let isoString, !isoString.isEmpty, let date = isoFormatter.date(
from: isoString) else { from: isoString) else {
return nil return nil

View file

@ -1,11 +1,6 @@
import SwiftCrossUI import SwiftCrossUI
import Foundation import Foundation
extension Train {
var serviceDate: String {
AmtrakDateFormatting.date(from: createdAt) ?? "-"
}
}
struct ContentView: View { struct ContentView: View {
@State var client = AmtrakClient() @State var client = AmtrakClient()
@ -15,7 +10,9 @@ struct ContentView: View {
@State var isLoading = false @State var isLoading = false
@State var errorMessage: String? @State var errorMessage: String?
@State var currentStation: String = "" @State var currentStation: String = ""
@State var localTimeZone: TimeZone?
let amtrakDate = AmtrakDateFormatting()
let unknown = "Unknown" let unknown = "Unknown"
var body: some View { var body: some View {
@ -25,11 +22,11 @@ struct ContentView: View {
TextField("e.g. ASD", text: $stationCode) TextField("e.g. ASD", text: $stationCode)
.frame(width: 100) .frame(width: 100)
Button("Refresh") { Button("Submit") {
Task { Task {
await loadTrainStatus(forStationCode: normalizedStationCode) await loadTrainStatus(forStationCode: normalizedStationCode)
} }
}.disabled(normalizedStationCode.isEmpty || isLoading) }.disabled(normalizedStationCode.isEmpty)
}.padding(10) }.padding(10)
// Most popular Virtual Railfan stations // Most popular Virtual Railfan stations
@ -57,12 +54,22 @@ struct ContentView: View {
TableColumn("Time", value: \TrainStatusRow.arrival) TableColumn("Time", value: \TrainStatusRow.arrival)
TableColumn("Number", value: \TrainStatusRow.trainNum) TableColumn("Number", value: \TrainStatusRow.trainNum)
TableColumn("Train", value: \TrainStatusRow.routeName) TableColumn("Train", value: \TrainStatusRow.routeName)
TableColumn("To", value: \TrainStatusRow.origin) TableColumn("To", value: \TrainStatusRow.destination)
TableColumn("From", value: \TrainStatusRow.destination) TableColumn("From", value: \TrainStatusRow.origin)
TableColumn("Status", value: \TrainStatusRow.status) 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) TableColumn("Track", value: \TrainStatusRow.platform)
}.frame(minWidth: 600)
}.overlay(alignment: .bottomTrailing) { .overlay(alignment: .bottomTrailing) {
if isLoading { if isLoading {
ProgressView() ProgressView()
.frame(alignment: .topTrailing) .frame(alignment: .topTrailing)
@ -74,7 +81,9 @@ struct ContentView: View {
if let errorMessage { if let errorMessage {
Text(errorMessage) Text(errorMessage)
} else if !currentStation.isEmpty { } else if !currentStation.isEmpty {
Text(currentStation) Text(
"It is \(amtrakDate.localTime(timeZone: localTimeZone)) at \(currentStation) station"
)
} }
}.padding(10) }.padding(10)
} }
@ -89,6 +98,21 @@ struct ContentView: View {
var normalizedStationCode: String { var normalizedStationCode: String {
stationCode.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() 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 { func loadTrainStatus(forStationCode code: String) async {
guard !code.isEmpty else { guard !code.isEmpty else {
@ -101,25 +125,32 @@ struct ContentView: View {
defer { isLoading = false } defer { isLoading = false }
do { do {
let trainNumbers = try await client.fetchStationTrainNumbers( let lookup = try await client.trainStationLookup(
stationCode: code, stationCode: code,
) )
guard !trainNumbers.isEmpty else { guard !lookup.trainNumbers.isEmpty else {
errorMessage = "\(code) has no trains listed right now" errorMessage = "\(code) has no trains listed right now"
rows = [] rows = []
return return
} }
var newRows: [TrainStatusRow] = [] var newRows: [TrainStatusRow] = []
for number in trainNumbers { for number in lookup.trainNumbers {
let trains = try await client.fetchAllTrainStatus(num: number) let trains = try await client.fetchAllTrainStatus(num: number)
for train in trains { for train in trains {
let platform = train.stations?.first( let platform = train.stations?.first(
where: { $0.code == code where: { $0.code == code
}, },
)?.platform )?.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 { func isOnTime(arr: String?, schArr: String?) -> TrainLateness {
let amtrakDate = AmtrakDateFormatting()
guard
if AmtrakDateFormatting let actualDate = amtrakDate.parsedDate(from: arr),
.time(from: arr) != AmtrakDateFormatting let scheduledDate = amtrakDate.parsedDate(from: schArr)
.time(from: schArr) { else {
return "Late" 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 leg = train.stations?.first { $0.code == code }
let platform = leg?.platform ?? "-"
let stationName = leg?.name ?? unknown let stationName = leg?.name ?? unknown
let arrival = AmtrakDateFormatting.time(from: leg?.arr) ?? AmtrakDateFormatting.time( let arrival = amtrakDate.time(from: leg?.arr, timeZone: timeZone) ?? amtrakDate.time(
from: leg?.schArr from: leg?.schArr,
) timeZone: timeZone)
let depature = AmtrakDateFormatting.time(from: leg?.dep) ?? AmtrakDateFormatting.time( let depature = amtrakDate.time(from: leg?.dep, timeZone: timeZone) ?? amtrakDate.time(
from: leg?.schDep from: leg?.schDep,
) timeZone: timeZone)
currentStation = "\(stationName) station" currentStation = stationName
localTimeZone = timeZone
return TrainStatusRow( return TrainStatusRow(
trainID: train.trainID, trainID: train.trainID,

View file

@ -97,7 +97,7 @@ struct TrainStatusRow: Identifiable {
var trainID: String var trainID: String
var trainNum: String var trainNum: String
var routeName: String var routeName: String
var status: String var status: TrainLateness
var platform: String var platform: String
var arrival: String var arrival: String
var departure: String var departure: String

View file

@ -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"
}
}

BIN
assets/screenshot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 449 KiB

23
changelog.md Normal file
View file

@ -0,0 +1,23 @@
# Change Log
## 0.2.102
- The status bar at the bottom now shows ``It is <time> at <station> station``.
## 0.2.101
Completely rewrote from scratch (except the ported models) using [SwiftCrossUI](https://github.com/moreSwift/swift-cross-ui). This new version now adopts to whatever system it was compiled for. A huge step above from pointing to a web page. While this did involve a lot of work to get it back up to speed, it was wroth it.
## 0.1.104
- New buttons for major terminals and rail cams.
- Version displayed above credits.
## 0.1.102
- Redesigned icons
- Added banner above form
## 0.1.101
- First public prototype