diff --git a/Bundler.toml b/Bundler.toml index 142cdbf..f8a1d93 100644 --- a/Bundler.toml +++ b/Bundler.toml @@ -1,3 +1,4 @@ +# https://swiftbundler.dev/documentation/swift-bundler/configuration format_version = 2 [apps.AmtrakStatus] @@ -5,6 +6,7 @@ identifier = 'com.tonybark.AmtrakStatus' product = 'AmtrakStatus' version = '0.2.103' icon = "./assets/icon.png" +category = "public.app-category.travel" [[apps.AmtrakStatus.overlays]] diff --git a/Sources/AmtrakStatus/AmtrakClient.swift b/Sources/AmtrakStatus/AmtrakClient.swift index 0fbe23f..cd0a00d 100644 --- a/Sources/AmtrakStatus/AmtrakClient.swift +++ b/Sources/AmtrakStatus/AmtrakClient.swift @@ -27,6 +27,10 @@ struct AmtrakClient { 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) diff --git a/Sources/AmtrakStatus/AmtrakDateFormatting.swift b/Sources/AmtrakStatus/AmtrakDateFormatting.swift index 98042ec..d7b379e 100644 --- a/Sources/AmtrakStatus/AmtrakDateFormatting.swift +++ b/Sources/AmtrakStatus/AmtrakDateFormatting.swift @@ -26,21 +26,21 @@ struct AmtrakDateFormatting { return isoFormatter.date(from: isoString) } - func time(from isoString: String?, timeZone: TimeZone?, relativeTo otherZone: TimeZone? = nil) -> String? { + 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 { return nil } - timeFormatter.timeZone = timeZone + timeFormatter.timeZone = zone let formatted = timeFormatter.string(from: date) guard - let timeZone, - let otherZone, - timeZone.identifier != otherZone.identifier, - let abbreviation = timeZone.abbreviation(for: date) + let zone, + let originZone, + zone.identifier != originZone.identifier, + let abbreviation = zone.abbreviation(for: date) else { return formatted } @@ -48,6 +48,29 @@ struct AmtrakDateFormatting { return "\(formatted) \(abbreviation)" } + + func isOnTime(actual: String?, scheduled: String?) -> LateTrain { + + guard + let actualDate = parsedDate(from: actual), + let scheduledDate = parsedDate(from: scheduled) + else { + return .unknown + } + + 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 localTime(timeZone: TimeZone?) -> String { let now = Date() timeFormatter.timeZone = timeZone diff --git a/Sources/AmtrakStatus/ContentView.swift b/Sources/AmtrakStatus/ContentView.swift index 08c3708..1366751 100644 --- a/Sources/AmtrakStatus/ContentView.swift +++ b/Sources/AmtrakStatus/ContentView.swift @@ -14,6 +14,7 @@ struct ContentView: View { let amtrakDate = AmtrakDateFormatting() let unknown = "Unknown" + let staleDataThreshold: TimeInterval = 60 * 60 * 24 // 24 hours var body: some View { VStack { @@ -137,6 +138,7 @@ struct ContentView: View { } var zoneCache: [String: TimeZone?] = [code: lookup.timeZone] + let staleness = await staleTrainLookup() var newRows: [TrainStatusRow] = [] for number in lookup.trainNumbers { @@ -146,15 +148,16 @@ struct ContentView: View { where: { $0.code == code }, )?.platform - await newRows - .append( - trainStatus( + let row = await trainStatus( for: train, - atStationCode: code, - timeZone: lookup.timeZone, - zoneCache: &zoneCache + code: code, + zone: lookup.timeZone, + cache: &zoneCache, + staleness: staleness ) - ) + if let row { + newRows.append(row) + } } } @@ -169,34 +172,22 @@ struct ContentView: View { } } - func isOnTime(arr: String?, schArr: String?) -> TrainLateness { - - guard - let actualDate = amtrakDate.parsedDate(from: arr), - let scheduledDate = amtrakDate.parsedDate(from: schArr) - else { - return .unknown - } - - 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, - timeZone: TimeZone?, - zoneCache: inout [String: TimeZone?] - ) async -> TrainStatusRow { - let leg = train.stations?.first { $0.code == code } + 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 ?? "" @@ -210,12 +201,12 @@ struct ContentView: View { let time = amtrakDate.time( from: leg?.arr, - timeZone: timeZone, - relativeTo: originZone + in: timeZone, + comparedTo: originZone ) ?? amtrakDate.time( from: leg?.schArr, - timeZone: timeZone, - relativeTo: originZone + in: timeZone, + comparedTo: originZone ) currentStation = stationName @@ -229,14 +220,53 @@ struct ContentView: View { train: train.routeName, to: train.destName, from: train.origName, - status: isOnTime( - arr: leg?.arr, - schArr: leg?.schArr + 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 diff --git a/Sources/AmtrakStatus/TrainLateness.swift b/Sources/AmtrakStatus/LateTrain.swift similarity index 98% rename from Sources/AmtrakStatus/TrainLateness.swift rename to Sources/AmtrakStatus/LateTrain.swift index ab81940..c36ac7b 100644 --- a/Sources/AmtrakStatus/TrainLateness.swift +++ b/Sources/AmtrakStatus/LateTrain.swift @@ -1,7 +1,7 @@ import Foundation import SwiftCrossUI -enum TrainLateness { +enum LateTrain { case onTime case early(minutes: Int) case late(minutes: Int) diff --git a/Sources/AmtrakStatus/Models.swift b/Sources/AmtrakStatus/Models.swift index ece20f9..11d5aec 100644 --- a/Sources/AmtrakStatus/Models.swift +++ b/Sources/AmtrakStatus/Models.swift @@ -81,9 +81,15 @@ struct StationMeta: Codable { } struct StaleData: Codable { - var avgLastUpdate: Double var activeTrains: Int - var stale: Bool + var avgLastUpdate: Double + var lastUpdatedArr: [TrainFreshness] + var medianLastUpdate: Double +} + +struct TrainFreshness: Codable, Sendable { + var trainID: String + var timeSince: Double } typealias TrainResponse = [String: [Train]] @@ -99,6 +105,6 @@ struct TrainStatusRow: Identifiable { var train: String var to: String var from: String - var status: TrainLateness + var status: LateTrain var track: String }