mirror of
https://github.com/tonytins/amtkstat.git
synced 2026-10-24 05:32:54 -04:00
If both isStale() and isDataStale() are in an agreement, no train is added to the list.
284 lines
8.8 KiB
Swift
284 lines
8.8 KiB
Swift
import SwiftCrossUI
|
|
import Foundation
|
|
|
|
|
|
struct ContentView: View {
|
|
@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 * 24 // 24 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)
|
|
}
|
|
.frame(idealWidth: 500)
|
|
.onAppear {
|
|
Task {
|
|
await loadTrainStatus(forStationCode: normalizedStationCode)
|
|
}
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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
|
|
)
|
|
|
|
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 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
|
|
}
|
|
}
|