mirror of
https://github.com/tonytins/amtkstat.git
synced 2026-10-24 13:42:51 -04:00
Compare commits
No commits in common. "0543d43ba593ca36b4334842859453a489a3cc25" and "bf67475107934e743cdafc54bb46966b2ce35adc" have entirely different histories.
0543d43ba5
...
bf67475107
9 changed files with 50 additions and 191 deletions
|
|
@ -1,9 +1,9 @@
|
||||||
format_version = 2
|
format_version = 2
|
||||||
|
|
||||||
[apps.AmtrakStatus]
|
[apps.AmtrakStatus]
|
||||||
identifier = 'com.tonybark.AmtrakStatus'
|
identifier = 'com.example.AmtrakStatus'
|
||||||
product = 'AmtrakStatus'
|
product = 'AmtrakStatus'
|
||||||
version = '0.2.102'
|
version = '0.1.0'
|
||||||
icon = "./assets/icon.png"
|
icon = "./assets/icon.png"
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img title="" src="assets/banner.svg" alt="" data-align="center" width="80%">
|
<img title="" src="assets/banner.svg" alt="" data-align="center" width="718">
|
||||||
<img title="" src="assets/screenshot.png" alt="" data-align="center" width="70%">
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
## 🖥️ Supported Platforms
|
## 🖥️ Supported Platforms
|
||||||
|
|
@ -8,7 +7,7 @@
|
||||||
| Platform | Minimum Version |
|
| Platform | Minimum Version |
|
||||||
| ---------------------------- | --------------- |
|
| ---------------------------- | --------------- |
|
||||||
| macOS | 10.15+ |
|
| macOS | 10.15+ |
|
||||||
| Windows | 11+ |
|
| Windows | 10+ |
|
||||||
| Linux | gtk 3+ |
|
| Linux | gtk 3+ |
|
||||||
|
|
||||||
## 🔨 Setup
|
## 🔨 Setup
|
||||||
|
|
@ -36,9 +35,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. I'm just not that good with Node.
|
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 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.
|
So it sat abandoned until I discovered SwiftCrossUI. Now it directly makes use of [Amtraker](https://amtraker.com/)'s API.
|
||||||
|
|
||||||
## ⚖️ License
|
## ⚖️ License
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,12 +7,6 @@ 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()
|
||||||
|
|
||||||
|
|
@ -48,20 +42,14 @@ struct AmtrakClient {
|
||||||
.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)
|
||||||
}
|
}
|
||||||
|
|
||||||
let trainNumbers = Array(Set(meta.trains.map(trainNumber(fromTrainId:))))
|
return 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 {
|
||||||
|
|
|
||||||
|
|
@ -1,52 +1,32 @@
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
struct AmtrakDateFormatting {
|
struct AmtrakDateFormatting {
|
||||||
let isoFormatter = ISO8601DateFormatter()
|
static let isoFormatter = ISO8601DateFormatter()
|
||||||
|
|
||||||
let timeFormatter: DateFormatter = {
|
static 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
|
||||||
}()
|
}()
|
||||||
|
|
||||||
let dateFormatter: DateFormatter = {
|
static 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
|
||||||
}()
|
}()
|
||||||
|
|
||||||
func parsedDate(from isoString: String?) -> Date? {
|
static func time(from isoString: String?) -> String? {
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
func localTime(timeZone: TimeZone?) -> String {
|
static func date(from isoString: String?) -> 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
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,11 @@
|
||||||
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()
|
||||||
|
|
@ -10,9 +15,7 @@ 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 {
|
||||||
|
|
@ -22,11 +25,11 @@ struct ContentView: View {
|
||||||
TextField("e.g. ASD", text: $stationCode)
|
TextField("e.g. ASD", text: $stationCode)
|
||||||
.frame(width: 100)
|
.frame(width: 100)
|
||||||
|
|
||||||
Button("Submit") {
|
Button("Refresh") {
|
||||||
Task {
|
Task {
|
||||||
await loadTrainStatus(forStationCode: normalizedStationCode)
|
await loadTrainStatus(forStationCode: normalizedStationCode)
|
||||||
}
|
}
|
||||||
}.disabled(normalizedStationCode.isEmpty)
|
}.disabled(normalizedStationCode.isEmpty || isLoading)
|
||||||
}.padding(10)
|
}.padding(10)
|
||||||
|
|
||||||
// Most popular Virtual Railfan stations
|
// Most popular Virtual Railfan stations
|
||||||
|
|
@ -54,22 +57,12 @@ 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.destination)
|
TableColumn("To", value: \TrainStatusRow.origin)
|
||||||
TableColumn("From", value: \TrainStatusRow.origin)
|
TableColumn("From", value: \TrainStatusRow.destination)
|
||||||
TableColumn("Status") {
|
TableColumn("Status", value: \TrainStatusRow.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)
|
||||||
|
|
@ -81,9 +74,7 @@ struct ContentView: View {
|
||||||
if let errorMessage {
|
if let errorMessage {
|
||||||
Text(errorMessage)
|
Text(errorMessage)
|
||||||
} else if !currentStation.isEmpty {
|
} else if !currentStation.isEmpty {
|
||||||
Text(
|
Text(currentStation)
|
||||||
"It is \(amtrakDate.localTime(timeZone: localTimeZone)) at \(currentStation) station"
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}.padding(10)
|
}.padding(10)
|
||||||
}
|
}
|
||||||
|
|
@ -99,21 +90,6 @@ struct ContentView: View {
|
||||||
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 {
|
||||||
return
|
return
|
||||||
|
|
@ -125,32 +101,25 @@ struct ContentView: View {
|
||||||
defer { isLoading = false }
|
defer { isLoading = false }
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let lookup = try await client.trainStationLookup(
|
let trainNumbers = try await client.fetchStationTrainNumbers(
|
||||||
stationCode: code,
|
stationCode: code,
|
||||||
)
|
)
|
||||||
|
|
||||||
guard !lookup.trainNumbers.isEmpty else {
|
guard !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 lookup.trainNumbers {
|
for number in 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
|
newRows.append(trainStatus(for: train, atStationCode: code))
|
||||||
.append(
|
|
||||||
trainStatus(
|
|
||||||
for: train,
|
|
||||||
atStationCode: code,
|
|
||||||
timeZone: lookup.timeZone
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -165,39 +134,30 @@ struct ContentView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func isOnTime(arr: String?, schArr: String?) -> TrainLateness {
|
func isOnTime(arr: String?, schArr: String?) -> String {
|
||||||
|
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let differenceInMinutes = Int(actualDate.timeIntervalSince(scheduledDate) / 60)
|
return "On Time"
|
||||||
|
|
||||||
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, timeZone: TimeZone?) -> TrainStatusRow {
|
func trainStatus(for train: Train, atStationCode code: String) -> 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 = amtrakDate.time(from: leg?.arr, timeZone: timeZone) ?? amtrakDate.time(
|
let arrival = AmtrakDateFormatting.time(from: leg?.arr) ?? AmtrakDateFormatting.time(
|
||||||
from: leg?.schArr,
|
from: leg?.schArr
|
||||||
timeZone: timeZone)
|
)
|
||||||
let depature = amtrakDate.time(from: leg?.dep, timeZone: timeZone) ?? amtrakDate.time(
|
let depature = AmtrakDateFormatting.time(from: leg?.dep) ?? AmtrakDateFormatting.time(
|
||||||
from: leg?.schDep,
|
from: leg?.schDep
|
||||||
timeZone: timeZone)
|
)
|
||||||
|
|
||||||
currentStation = stationName
|
currentStation = "\(stationName) station"
|
||||||
localTimeZone = timeZone
|
|
||||||
|
|
||||||
return TrainStatusRow(
|
return TrainStatusRow(
|
||||||
trainID: train.trainID,
|
trainID: train.trainID,
|
||||||
|
|
|
||||||
|
|
@ -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: TrainLateness
|
var status: String
|
||||||
var platform: String
|
var platform: String
|
||||||
var arrival: String
|
var arrival: String
|
||||||
var departure: String
|
var departure: String
|
||||||
|
|
|
||||||
|
|
@ -1,45 +0,0 @@
|
||||||
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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 449 KiB |
23
changelog.md
23
changelog.md
|
|
@ -1,23 +0,0 @@
|
||||||
# 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
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue