Compare commits

..

No commits in common. "ca60e36ea24d7f0be7569fcc627557964b6de521" and "dc464de1c48bd81ee7a5ebe7a1bd583baee5f775" have entirely different histories.

9 changed files with 564 additions and 590 deletions

View file

@ -3,21 +3,21 @@
import PackageDescription import PackageDescription
let package = Package( let package = Package(
name: "AmtrakStatus", name: "AmtrakStatus",
platforms: [.macOS(.v10_15), .iOS(.v13), .tvOS(.v13), .macCatalyst(.v13)], platforms: [.macOS(.v10_15), .iOS(.v13), .tvOS(.v13), .macCatalyst(.v13)],
dependencies: [ dependencies: [
.package( .package(
url: "https://github.com/moreSwift/swift-cross-ui", url: "https://github.com/moreSwift/swift-cross-ui",
.upToNextMinor(from: "0.7.0"), .upToNextMinor(from: "0.7.0"),
) ),
], ],
targets: [ targets: [
.executableTarget( .executableTarget(
name: "AmtrakStatus", name: "AmtrakStatus",
dependencies: [ dependencies: [
.product(name: "SwiftCrossUI", package: "swift-cross-ui"), .product(name: "SwiftCrossUI", package: "swift-cross-ui"),
.product(name: "DefaultBackend", package: "swift-cross-ui"), .product(name: "DefaultBackend", package: "swift-cross-ui"),
], ],
) ),
], ],
) )

View file

@ -1,85 +1,87 @@
import Foundation import Foundation
enum AmtrakError: Error { enum AmtrakError: Error {
case invalidURL(String) case invalidURL(String)
case invalidResponse case invalidResponse
case httpStatus(Int) case httpStatus(Int)
case stationNotFound(String) case stationNotFound(String)
} }
struct TrainStationLookUp: Sendable { struct TrainStationLookUp: Sendable {
var trainNumbers: [String] var trainNumbers: [String]
var timeZone: TimeZone? var timeZone: TimeZone?
} }
struct AmtrakClient { struct AmtrakClient {
var decoder = JSONDecoder() var decoder = JSONDecoder()
let stationBaseURL = "https://api.amtraker.com/v3/stations" let stationBaseURL = "https://api.amtraker.com/v3/stations"
let trainsBaseURL = "https://api.amtraker.com/v3/trains" let trainsBaseURL = "https://api.amtraker.com/v3/trains"
let staleBaseURL = "https://api.amtraker.com/v3/stale" let staleBaseURL = "https://api.amtraker.com/v3/stale"
func fetchTrain(number trainid: String) async throws -> TrainResponse { func fetchTrain(number trainid: String) async throws -> TrainResponse {
try await get("\(trainsBaseURL)/\(trainid)") 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)
} }
let trainNumbers = Array(Set(meta.trains.map(trainNumber(fromTrainId:)))) func fetchAllTrains() async throws -> TrainResponse {
let timeZone = meta.tz.flatMap(TimeZone.init(identifier:)) try await get(trainsBaseURL)
return TrainStationLookUp( }
trainNumbers: trainNumbers,
timeZone: timeZone func fetchStaleStatus() async throws -> StaleData {
) try await get(staleBaseURL)
}
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) func fetchAllTrainStatus(num trainNum: String) async throws -> [Train] {
guard let http = reponse as? HTTPURLResponse else { let reponse = try await fetchTrain(number: trainNum)
throw AmtrakError.invalidResponse return reponse.values.flatMap(\.self)
} }
guard (200..<300).contains(http.statusCode) else { func fetchStation(code stationid: String) async throws -> StationResponse {
throw AmtrakError.httpStatus(http.statusCode) try await get("\(stationBaseURL)/\(stationid)")
} }
return try decoder.decode(T.self, from: data) 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)
}
} }

View file

@ -1,99 +1,93 @@
import Foundation import Foundation
struct AmtrakDateFormatting { struct AmtrakDateFormatting {
let isoFormatter = ISO8601DateFormatter() let isoFormatter = ISO8601DateFormatter()
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
}() }()
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
}() }()
func parsedDate(from isoString: String?) -> Date? { func parsedDate(from isoString: String?) -> Date? {
guard let isoString, !isoString.isEmpty, guard let isoString, !isoString.isEmpty, let date = isoFormatter.date(
let date = isoFormatter.date( from: isoString) else {
from: isoString) return nil
else { }
return nil
return isoFormatter.date(from: isoString)
} }
return isoFormatter.date(from: isoString) 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 {
func time(from isoString: String?, in zone: TimeZone?, comparedTo originZone: TimeZone? = nil) return nil
-> String? }
{
guard let isoString, !isoString.isEmpty, timeFormatter.timeZone = zone
let date = isoFormatter.date(
from: isoString) let formatted = timeFormatter.string(from: date)
else {
return nil guard
let zone,
let originZone,
zone.identifier != originZone.identifier,
let abbreviation = zone.abbreviation(for: date)
else {
return formatted
}
return "\(formatted) \(abbreviation)"
} }
timeFormatter.timeZone = zone
func isOnTime(actual: String?, scheduled: String?) -> LateTrain {
let formatted = timeFormatter.string(from: date)
guard
guard let actualDate = parsedDate(from: actual),
let zone, let scheduledDate = parsedDate(from: scheduled)
let originZone, else {
zone.identifier != originZone.identifier, return .unknown
let abbreviation = zone.abbreviation(for: date) }
else {
return formatted 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)
}
} }
return "\(formatted) \(abbreviation)"
} func localTime(timeZone: TimeZone?) -> String {
let now = Date()
func isOnTime(actual: String?, scheduled: String?) -> LateTrain { timeFormatter.timeZone = timeZone
return timeFormatter.string(from: now)
guard
let actualDate = parsedDate(from: actual),
let scheduledDate = parsedDate(from: scheduled)
else {
return .unknown
} }
let differenceInMinutes = Int(actualDate.timeIntervalSince(scheduledDate) / 60) func updateTime(timeZone: TimeZone?) {
let now = Date()
switch differenceInMinutes { timeFormatter.timeZone = timeZone
case -1...1:
return .onTime
case ..<0:
return .early(minutes: -differenceInMinutes)
default:
return .late(minutes: differenceInMinutes)
} }
}
func date(from isoString: String?) -> String? {
func localTime(timeZone: TimeZone?) -> String { guard let isoString, !isoString.isEmpty, let date = isoFormatter.date(
let now = Date() from: isoString) else {
timeFormatter.timeZone = timeZone return nil
return timeFormatter.string(from: now) }
}
return dateFormatter.string(from: date)
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
} }
return dateFormatter.string(from: date)
}
} }

View file

@ -3,9 +3,9 @@ import SwiftCrossUI
@main @main
struct AmtrakStatusApp: App { struct AmtrakStatusApp: App {
var body: some Scene { var body: some Scene {
WindowGroup("AMTK Status") { WindowGroup("Amtrak Status") {
ContentView() ContentView()
}
} }
}
} }

View file

@ -1,293 +1,288 @@
import Foundation
import SwiftCrossUI import SwiftCrossUI
import Foundation
struct ContentView: View { struct ContentView: View {
@State var client = AmtrakClient() @State var client = AmtrakClient()
@State var stationCode = "ASD" // Ashland, VA @State var stationCode = "ASD" // Ashland, VA
@State var rows: [TrainStatusRow] = [] @State var rows: [TrainStatusRow] = []
@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? @State var localTimeZone: TimeZone?
let amtrakDate = AmtrakDateFormatting()
let unknown = "Unknown"
let staleDataThreshold: TimeInterval = 60 * 60 * 48 // 48 hours
let amtrakDate = AmtrakDateFormatting() var body: some View {
let unknown = "Unknown" VStack {
let staleDataThreshold: TimeInterval = 60 * 60 * 48 // 48 hours HStack {
Text("Station Code:")
TextField("e.g. ASD", text: $stationCode)
.frame(width: 100)
var body: some View { Button("Submit") {
VStack { Task {
HStack { await loadTrainStatus(forStationCode: normalizedStationCode)
Text("Station Code:") }
TextField("e.g. ASD", text: $stationCode) }.disabled(normalizedStationCode.isEmpty)
.frame(width: 100) }.padding(10)
Button("Submit") { // Most popular Virtual Railfan stations
Task { HStack {
await loadTrainStatus(forStationCode: normalizedStationCode) Button("Ashland, VA") {
} Task {
}.disabled(normalizedStationCode.isEmpty) await loadTrainStatus(forStationCode: "ASD")
}.padding(10) }
}
// Most popular Virtual Railfan stations Button("San Juan Capistrano, CA") {
HStack { Task {
Button("Ashland, VA") { await loadTrainStatus(forStationCode: "SNC")
Task { }
await loadTrainStatus(forStationCode: "ASD") }
}
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)
}
}.overlay(alignment: .topTrailing) {
Text("Information by Amtraker")
.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
} }
Button("San Juan Capistrano, CA") { isLoading = true
Task { errorMessage = nil
await loadTrainStatus(forStationCode: "SNC")
} 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 = []
} }
}
Button("La Plata, MO") {
Task { func trainStatus(
await loadTrainStatus(forStationCode: "LAP") 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
Table(rows) { let track = leg?.platform ?? ""
TableColumn("Time", value: \TrainStatusRow.time)
TableColumn("Number", value: \TrainStatusRow.trainNum) var originZone: TimeZone?
TableColumn("", value: \TrainStatusRow.presence.rawValue) if let orgCode = train.origCode {
TableColumn("Train", value: \TrainStatusRow.train) originZone = await fetchTimeZone(
TableColumn("To", value: \TrainStatusRow.to) forStationCode: orgCode,
TableColumn("From", value: \TrainStatusRow.from) cache: &zoneCache
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) let time = amtrakDate.time(
.overlay(alignment: .bottomTrailing) { from: leg?.arr,
if isLoading { in: timeZone,
ProgressView() comparedTo: originZone
.frame(alignment: .topTrailing) ) ?? amtrakDate.time(
.padding(10) from: leg?.schArr,
} in: timeZone,
comparedTo: originZone
)
if stationName != unknown {
currentStation = stationName
localTimeZone = timeZone
} }
HStack { return TrainStatusRow(
if let errorMessage { trainID: train.trainID,
Text(errorMessage) time: time ?? "",
} else if !currentStation.isEmpty { trainNum: train.trainNum,
Text( presence: leg?.status ?? .unknown, // Yeah... It's a little confusing
"It is \(amtrakDate.localTime(timeZone: localTimeZone)) at \(currentStation) station" 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
} }
}.padding(10)
var calendar = Calendar.current
calendar.timeZone = timeZone ?? .current
return calendar
.compare(
deparatureDate,
to: Date(),
toGranularity: .day
) == .orderedAscending
} }
.frame(idealWidth: 500)
.onAppear { func isDataStale(
Task { _ trainId: String,
await loadTrainStatus(forStationCode: normalizedStationCode) staleness: [String: TimeInterval]
} ) -> Bool {
}.overlay(alignment: .topTrailing) { guard let timeSince = staleness[trainId] else { return false }
Text("Information by Amtraker") return timeSince > staleDataThreshold
.padding(10)
} }
}
func staleTrainLookup() async -> [String: TimeInterval] {
var normalizedStationCode: String { guard let staleData = try? await client.fetchStaleStatus() else {
stationCode.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() return [:]
}
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)
}
} }
} return Dictionary(
staleData.lastUpdatedArr.map { ( $0.trainID, $0.timeSince) },
rows = newRows uniquingKeysWith: { first, _ in first }
)
} catch let AmtrakError.stationNotFound(code) {
errorMessage = "Station \(code) not found"
rows = []
} catch {
errorMessage = "Couldn't load train status: \(error.localizedDescription)"
rows = []
} }
}
func fetchTimeZone(forStationCode code: String, cache: inout [String: TimeZone?]) async -> TimeZone? {
func trainStatus( if let cached = cache[code] {
for train: Train, return cached
code stationCode: String, }
zone timeZone: TimeZone?, let zone: TimeZone?
cache zoneCache: inout [String: TimeZone?], do {
staleness: [String: TimeInterval] let response = try await client.fetchStation(code: code)
) async -> TrainStatusRow? { zone = response[code]?.tz.flatMap(TimeZone.init(identifier:))
let leg = train.stations?.first { $0.code == stationCode } } catch {
zone = nil
guard }
!isStale(leg, timeZone: timeZone), !isDataStale(train.trainID, staleness: staleness)
else { return zone
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
)
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 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
}
} }

View file

@ -1,7 +1,7 @@
import Foundation import Foundation
extension Int { extension Int {
func pluralized(singular: String, plural: String) -> String { func pluralized(singular: String, plural: String) -> String {
self == 1 ? singular : plural self == 1 ? singular : plural
} }
} }

View file

@ -2,48 +2,48 @@ import Foundation
import SwiftCrossUI import SwiftCrossUI
enum LateTrain { enum LateTrain {
case onTime case onTime
case early(minutes: Int) case early(minutes: Int)
case late(minutes: Int) case late(minutes: Int)
case unknown case unknown
var descrption: String { var descrption: String {
switch self { switch self {
case .onTime: case .onTime:
return "On Time" return "On Time"
case .early(let minutes): case .early(minutes: let minutes):
return "\(Self.formatted(minutes)) early" return "\(Self.formatted(minutes)) early"
case .late(let minutes): case .late(minutes: let minutes):
return "\(Self.formatted(minutes)) late" return "\(Self.formatted(minutes)) late"
case .unknown: case .unknown:
return "" return ""
}
} }
}
var color: Color {
var color: Color { switch self {
switch self { case .onTime:
case .onTime: return .green
return .green case .early:
case .early: return .blue
return .blue case .late:
case .late: return .red
return .red case .unknown:
case .unknown: return .gray
return .gray }
} }
}
static func formatted(_ minutes: Int) -> String {
static func formatted(_ minutes: Int) -> String { let hours = minutes / 60
let hours = minutes / 60 let remainder = minutes % 60
let remainder = minutes % 60
let minutesPart = "\(minutes) \(minutes.pluralized(singular: "min", plural: "mins"))"
let minutesPart = "\(minutes) \(minutes.pluralized(singular: "min", plural: "mins"))" let hoursPart = "\(hours) \(hours.pluralized(singular: "hr", plural: "hrs"))"
let hoursPart = "\(hours) \(hours.pluralized(singular: "hr", plural: "hrs"))"
guard hours > 0 else {
guard hours > 0 else { return minutesPart
return minutesPart }
return hoursPart
} }
return hoursPart
}
} }

View file

@ -1,110 +1,110 @@
import Foundation import Foundation
struct TrainAlert: Codable { struct TrainAlert: Codable {
var message: String var message: String
} }
struct Station: Codable, Sendable { struct Station: Codable, Sendable {
var name: String var name: String
var code: String var code: String
var tz: String var tz: String
var bus: Bool var bus: Bool
var schArr: String var schArr: String
var schDep: String var schDep: String
var arr: String var arr: String
var dep: String var dep: String
var arrCmnt: String var arrCmnt: String
var depCmnt: String var depCmnt: String
var status: StationStatus var status: StationStatus
var stopIconColor: String var stopIconColor: String
var platform: String var platform: String
} }
enum StationStatus: String, Codable, Sendable { enum StationStatus: String, Codable, Sendable {
case enroute = "Enroute" case enroute = "Enroute"
case station = "Station" case station = "Station"
case departed = "Departed" case departed = "Departed"
case unknown = "Unknown" case unknown = "Unknown"
init(from decoder: any Decoder) throws { init(from decoder: any Decoder) throws {
let container = try decoder.singleValueContainer() let container = try decoder.singleValueContainer()
let rawValue = try container.decode(String.self) let rawValue = try container.decode(String.self)
self = StationStatus(rawValue: rawValue) ?? .unknown self = StationStatus(rawValue: rawValue) ?? .unknown
} }
} }
struct Train: Codable { struct Train: Codable {
var routeName: String var routeName: String
var trainNum: String var trainNum: String
var trainNumRaw: String? var trainNumRaw: String?
var trainID: String var trainID: String
var lat: Double? var lat: Double?
var lon: Double? var lon: Double?
var trainTimely: String? var trainTimely: String?
var iconColor: String? var iconColor: String?
var textColor: String? var textColor: String?
var heading: String? var heading: String?
var eventCode: String? var eventCode: String?
var eventTZ: String? var eventTZ: String?
var eventName: String? var eventName: String?
var origCode: String? var origCode: String?
var origName: String var origName: String
var destCode: String? var destCode: String?
var destName: String var destName: String
var trainState: String? var trainState: String?
var velocity: Double? var velocity: Double?
var statusMsg: String? var statusMsg: String?
var createdAt: String? var createdAt: String?
var updatedAt: String? var updatedAt: String?
var lastValTS: String? var lastValTS: String?
var objectID: Int? var objectID: Int?
var provider: String? var provider: String?
var providerShort: String? var providerShort: String?
var onlyOfTrainNum: Bool? var onlyOfTrainNum: Bool?
var alerts: [TrainAlert]? var alerts: [TrainAlert]?
var stations: [Station]? var stations: [Station]?
} }
struct StationMeta: Codable { struct StationMeta: Codable {
var name: String var name: String
var code: String var code: String
var tz: String? var tz: String?
var lat: Double? var lat: Double?
var lon: Double? var lon: Double?
var hasAddress: Bool? var hasAddress: Bool?
var address1: String? var address1: String?
var address2: String? var address2: String?
var city: String? var city: String?
var state: String? var state: String?
var zip: String? var zip: String?
var trains: [String] var trains: [String]
} }
struct StaleData: Codable { struct StaleData: Codable {
var activeTrains: Int var activeTrains: Int
var avgLastUpdate: Double var avgLastUpdate: Double
var lastUpdatedArr: [TrainFreshness] var lastUpdatedArr: [TrainFreshness]
var medianLastUpdate: Double var medianLastUpdate: Double
} }
struct TrainFreshness: Codable, Sendable { struct TrainFreshness: Codable, Sendable {
var trainID: String var trainID: String
var timeSince: Double var timeSince: Double
} }
typealias TrainResponse = [String: [Train]] typealias TrainResponse = [String: [Train]]
typealias StationResponse = [String: StationMeta] typealias StationResponse = [String: StationMeta]
struct TrainStatusRow: Identifiable { struct TrainStatusRow: Identifiable {
var id: String { trainID } var id: String { trainID }
var trainID: String var trainID: String
var time: String var time: String
var trainNum: String var trainNum: String
var presence: StationStatus var presence: StationStatus
var train: String var train: String
var to: String var to: String
var from: String var from: String
var status: LateTrain var status: LateTrain
var track: String var track: String
} }

View file

@ -1,17 +0,0 @@
#!/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