diff --git a/iosApp/iosApp/ContentView.swift b/iosApp/iosApp/ContentView.swift index 72bca8541..ea8134c79 100644 --- a/iosApp/iosApp/ContentView.swift +++ b/iosApp/iosApp/ContentView.swift @@ -49,7 +49,8 @@ struct ContentView: View { nearbyFetcher: nearbyFetcher, scheduleFetcher: scheduleFetcher, predictionsFetcher: predictionsFetcher, - viewportProvider: viewportProvider + viewportProvider: viewportProvider, + alertsFetcher: alertsFetcher ) } } diff --git a/iosApp/iosApp/Fetchers/NearbyFetcher.swift b/iosApp/iosApp/Fetchers/NearbyFetcher.swift index 123c1fbca..62b0d07ff 100644 --- a/iosApp/iosApp/Fetchers/NearbyFetcher.swift +++ b/iosApp/iosApp/Fetchers/NearbyFetcher.swift @@ -79,6 +79,7 @@ class NearbyFetcher: ObservableObject { func withRealtimeInfo( schedules: ScheduleResponse?, predictions: PredictionsStreamDataResponse?, + alerts: AlertsStreamDataResponse?, filterAtTime: Instant ) -> [StopAssociatedRoute]? { guard let loadedLocation else { return nil } @@ -86,6 +87,7 @@ class NearbyFetcher: ObservableObject { sortByDistanceFrom: .init(longitude: loadedLocation.longitude, latitude: loadedLocation.latitude), schedules: schedules, predictions: predictions, + alerts: alerts, filterAtTime: filterAtTime ) } diff --git a/iosApp/iosApp/Localizable.xcstrings b/iosApp/iosApp/Localizable.xcstrings index dd0ca50d3..7e54afe34 100644 --- a/iosApp/iosApp/Localizable.xcstrings +++ b/iosApp/iosApp/Localizable.xcstrings @@ -27,6 +27,9 @@ }, "Couldn't load nearby transit, unable to parse response" : { + }, + "Detour" : { + }, "Failed to load alerts, could not connect to the server" : { @@ -54,12 +57,24 @@ }, "No results found" : { + }, + "No Service" : { + }, "Routes" : { + }, + "Shuttle" : { + + }, + "Stop Closed" : { + }, "Stops" : { + }, + "Suspension" : { + } }, "version" : "1.0" diff --git a/iosApp/iosApp/Pages/NearbyTransit/NearbyStopRoutePatternView.swift b/iosApp/iosApp/Pages/NearbyTransit/NearbyStopRoutePatternView.swift index 5d4c92b8b..29a365f44 100644 --- a/iosApp/iosApp/Pages/NearbyTransit/NearbyStopRoutePatternView.swift +++ b/iosApp/iosApp/Pages/NearbyTransit/NearbyStopRoutePatternView.swift @@ -33,14 +33,18 @@ struct NearbyStopRoutePatternView: View { case loading case none case some([TripWithFormat]) + case noService(shared.Alert) - static func from(upcomingTrips: [UpcomingTrip]?, now: Instant) -> Self { + static func from(upcomingTrips: [UpcomingTrip]?, alertsHere: [shared.Alert]?, now: Instant) -> Self { guard let upcomingTrips else { return .loading } let tripsToShow = upcomingTrips .map { TripWithFormat($0, now: now) } .filter { !$0.isHidden() } .prefix(2) if tripsToShow.isEmpty { + if let alert = alertsHere?.first { + return .noService(alert) + } return .none } return .some(Array(tripsToShow)) @@ -56,6 +60,8 @@ struct NearbyStopRoutePatternView: View { ForEach(predictions) { prediction in UpcomingTripView(prediction: .some(prediction.format)) } + case let .noService(alert): + UpcomingTripView(prediction: .noService(alert.effect)) case .none: UpcomingTripView(prediction: .none) case .loading: @@ -64,3 +70,23 @@ struct NearbyStopRoutePatternView: View { } } } + +struct NearbyStopRoutePatternView_Previews: PreviewProvider { + static var previews: some View { + VStack(alignment: .trailing) { + let now = Date.now + NearbyStopRoutePatternView(headsign: "Some", predictions: .some([ + .init(.init(prediction: ObjectCollectionBuilder.Single.shared.prediction { prediction in + prediction.departureTime = now.addingTimeInterval(5 * 60).toKotlinInstant() + }), now: now.toKotlinInstant()), + ])) + NearbyStopRoutePatternView(headsign: "None", predictions: .none) + NearbyStopRoutePatternView(headsign: "Loading", predictions: .loading) + NearbyStopRoutePatternView(headsign: "No Service", predictions: .noService( + ObjectCollectionBuilder.Single.shared.alert { alert in + alert.effect = .suspension + } + )) + } + } +} diff --git a/iosApp/iosApp/Pages/NearbyTransit/NearbyStopView.swift b/iosApp/iosApp/Pages/NearbyTransit/NearbyStopView.swift index 22afda74e..b2ba38660 100644 --- a/iosApp/iosApp/Pages/NearbyTransit/NearbyStopView.swift +++ b/iosApp/iosApp/Pages/NearbyTransit/NearbyStopView.swift @@ -21,7 +21,8 @@ struct NearbyStopView: View { ForEach(patternsAtStop.patternsByHeadsign, id: \.headsign) { patternsByHeadsign in NearbyStopRoutePatternView( headsign: patternsByHeadsign.headsign, - predictions: .from(upcomingTrips: patternsByHeadsign.upcomingTrips, now: now) + predictions: .from(upcomingTrips: patternsByHeadsign.upcomingTrips, + alertsHere: patternsByHeadsign.alertsHere, now: now) ) } } diff --git a/iosApp/iosApp/Pages/NearbyTransit/NearbyTransitPageView.swift b/iosApp/iosApp/Pages/NearbyTransit/NearbyTransitPageView.swift index d5c004381..b05ba2114 100644 --- a/iosApp/iosApp/Pages/NearbyTransit/NearbyTransitPageView.swift +++ b/iosApp/iosApp/Pages/NearbyTransit/NearbyTransitPageView.swift @@ -19,6 +19,7 @@ struct NearbyTransitPageView: View { @ObservedObject var scheduleFetcher: ScheduleFetcher @ObservedObject var predictionsFetcher: PredictionsFetcher @ObservedObject var viewportProvider: ViewportProvider + @ObservedObject var alertsFetcher: AlertsFetcher @State var cancellables: [AnyCancellable] @State var locationProvider: NearbyTransitLocationProvider @@ -28,13 +29,15 @@ struct NearbyTransitPageView: View { nearbyFetcher: NearbyFetcher, scheduleFetcher: ScheduleFetcher, predictionsFetcher: PredictionsFetcher, - viewportProvider: ViewportProvider + viewportProvider: ViewportProvider, + alertsFetcher: AlertsFetcher ) { self.currentLocation = currentLocation self.nearbyFetcher = nearbyFetcher self.scheduleFetcher = scheduleFetcher self.predictionsFetcher = predictionsFetcher self.viewportProvider = viewportProvider + self.alertsFetcher = alertsFetcher cancellables = .init() locationProvider = .init( @@ -49,7 +52,8 @@ struct NearbyTransitPageView: View { locationProvider: locationProvider, nearbyFetcher: nearbyFetcher, scheduleFetcher: scheduleFetcher, - predictionsFetcher: predictionsFetcher + predictionsFetcher: predictionsFetcher, + alertsFetcher: alertsFetcher ) .onAppear { cancellables.append( diff --git a/iosApp/iosApp/Pages/NearbyTransit/NearbyTransitView.swift b/iosApp/iosApp/Pages/NearbyTransit/NearbyTransitView.swift index 33d49f0a3..be11c3fd4 100644 --- a/iosApp/iosApp/Pages/NearbyTransit/NearbyTransitView.swift +++ b/iosApp/iosApp/Pages/NearbyTransit/NearbyTransitView.swift @@ -19,6 +19,7 @@ struct NearbyTransitView: View { @ObservedObject var nearbyFetcher: NearbyFetcher @ObservedObject var scheduleFetcher: ScheduleFetcher @ObservedObject var predictionsFetcher: PredictionsFetcher + @ObservedObject var alertsFetcher: AlertsFetcher @State var now = Date.now let timer = Timer.publish(every: 5, on: .main, in: .common).autoconnect() @@ -29,6 +30,7 @@ struct NearbyTransitView: View { if let nearby = nearbyFetcher.withRealtimeInfo( schedules: scheduleFetcher.schedules, predictions: predictionsFetcher.predictions, + alerts: alertsFetcher.alerts, filterAtTime: now.toKotlinInstant() ) { List(nearby, id: \.route.id) { nearbyRoute in @@ -247,7 +249,8 @@ struct NearbyTransitView_Previews: PreviewProvider { upcomingTrips: [ UpcomingTrip(prediction: busPrediction1), UpcomingTrip(prediction: busPrediction2), - ] + ], + alertsHere: nil ), ] ), @@ -268,7 +271,8 @@ struct NearbyTransitView_Previews: PreviewProvider { upcomingTrips: [ UpcomingTrip(prediction: crPrediction1), UpcomingTrip(prediction: crPrediction2), - ] + ], + alertsHere: nil ), ] ), diff --git a/iosApp/iosApp/Pages/NearbyTransit/UpcomingTripView.swift b/iosApp/iosApp/Pages/NearbyTransit/UpcomingTripView.swift index 35a9ed38e..c632e8e75 100644 --- a/iosApp/iosApp/Pages/NearbyTransit/UpcomingTripView.swift +++ b/iosApp/iosApp/Pages/NearbyTransit/UpcomingTripView.swift @@ -23,6 +23,7 @@ struct UpcomingTripView: View { enum State: Equatable { case loading case none + case noService(shared.Alert.Effect) case some(UpcomingTrip.Format) } @@ -51,6 +52,8 @@ struct UpcomingTripView: View { case let .minutes(format): Text("\(format.minutes, specifier: "%ld") min") } + case let .noService(alertEffect): + NoServiceView(effect: .from(alertEffect: alertEffect)) case .none: Text("No Predictions") case .loading: @@ -60,3 +63,66 @@ struct UpcomingTripView: View { .frame(minWidth: 48, alignment: .trailing) } } + +struct NoServiceView: View { + let effect: Effect + + enum Effect { + case detour + case shuttle + case stopClosed + case suspension + case unknown + + static func from(alertEffect: shared.Alert.Effect) -> Self { + switch alertEffect { + case .detour: .detour + case .shuttle: .shuttle + case .stationClosure, .stopClosure: .stopClosed + case .suspension: .suspension + default: .unknown + } + } + } + + var body: some View { + HStack { + rawText + .font(.system(size: 12)) + .textCase(.uppercase) + rawImage + } + } + + var rawText: Text { + switch effect { + case .detour: Text("Detour") + case .shuttle: Text("Shuttle") + case .stopClosed: Text("Stop Closed") + case .suspension: Text("Suspension") + case .unknown: Text("No Service") + } + } + + var rawImage: Image { + switch effect { + case .detour: Image(systemName: "circle.fill") + case .shuttle: Image(systemName: "bus") + case .stopClosed: Image(systemName: "xmark.octagon.fill") + case .suspension: Image(systemName: "exclamationmark.triangle.fill") + case .unknown: Image(systemName: "questionmark.circle.fill") + } + } +} + +struct UpcomingTripView_Previews: PreviewProvider { + static var previews: some View { + VStack(alignment: .trailing) { + UpcomingTripView(prediction: .noService(.suspension)) + UpcomingTripView(prediction: .noService(.shuttle)) + UpcomingTripView(prediction: .noService(.stopClosure)) + UpcomingTripView(prediction: .noService(.detour)) + } + .previewDisplayName("No Service") + } +} diff --git a/iosApp/iosAppTests/Views/NearbyTransitViewTests.swift b/iosApp/iosAppTests/Views/NearbyTransitViewTests.swift index c6cce937a..28cb46bfa 100644 --- a/iosApp/iosAppTests/Views/NearbyTransitViewTests.swift +++ b/iosApp/iosAppTests/Views/NearbyTransitViewTests.swift @@ -34,7 +34,8 @@ final class NearbyTransitViewTests: XCTestCase { ), nearbyFetcher: NearbyFetcher(backend: IdleBackend()), scheduleFetcher: .init(backend: IdleBackend()), - predictionsFetcher: .init(socket: MockSocket()) + predictionsFetcher: .init(socket: MockSocket()), + alertsFetcher: .init(socket: MockSocket()) ) XCTAssertEqual(try sut.inspect().view(NearbyTransitView.self).vStack()[0].text().string(), "Loading...") } @@ -63,7 +64,7 @@ final class NearbyTransitViewTests: XCTestCase { ), nearbyFetcher: FakeNearbyFetcher(getNearbyExpectation: getNearbyExpectation), scheduleFetcher: .init(backend: IdleBackend()), - predictionsFetcher: .init(socket: MockSocket()) + predictionsFetcher: .init(socket: MockSocket()), alertsFetcher: .init(socket: MockSocket()) ) let hasAppeared = sut.on(\NearbyTransitView.didAppear) { _ in } @@ -153,7 +154,7 @@ final class NearbyTransitViewTests: XCTestCase { ), nearbyFetcher: Route52NearbyFetcher(), scheduleFetcher: .init(backend: IdleBackend()), - predictionsFetcher: .init(socket: MockSocket()) + predictionsFetcher: .init(socket: MockSocket()), alertsFetcher: .init(socket: MockSocket()) ) let routes = try sut.inspect().findAll(NearbyRouteView.self) @@ -238,7 +239,7 @@ final class NearbyTransitViewTests: XCTestCase { ), nearbyFetcher: Route52NearbyFetcher(), scheduleFetcher: FakeScheduleFetcher(objects), - predictionsFetcher: FakePredictionsFetcher(objects) + predictionsFetcher: FakePredictionsFetcher(objects), alertsFetcher: .init(socket: MockSocket()) ) let patterns = try sut.inspect().findAll(NearbyStopRoutePatternView.self) @@ -315,7 +316,7 @@ final class NearbyTransitViewTests: XCTestCase { ), nearbyFetcher: Route52NearbyFetcher(), scheduleFetcher: .init(backend: IdleBackend()), - predictionsFetcher: FakePredictionsFetcher(distantInstant: distantInstant) + predictionsFetcher: FakePredictionsFetcher(distantInstant: distantInstant), alertsFetcher: .init(socket: MockSocket()) ) let stops = try sut.inspect().findAll(NearbyStopView.self) @@ -374,7 +375,7 @@ final class NearbyTransitViewTests: XCTestCase { ), nearbyFetcher: nearbyFetcher, scheduleFetcher: .init(backend: IdleBackend()), - predictionsFetcher: predictionsFetcher + predictionsFetcher: predictionsFetcher, alertsFetcher: .init(socket: MockSocket()) ) ViewHosting.host(view: sut) @@ -404,7 +405,8 @@ final class NearbyTransitViewTests: XCTestCase { ), nearbyFetcher: Route52NearbyFetcher(), scheduleFetcher: .init(backend: IdleBackend()), - predictionsFetcher: predictionsFetcher + predictionsFetcher: predictionsFetcher, + alertsFetcher: .init(socket: MockSocket()) ) func prediction(minutesAway: Double) -> PredictionsStreamDataResponse { @@ -463,7 +465,7 @@ final class NearbyTransitViewTests: XCTestCase { ), nearbyFetcher: nearbyFetcher, scheduleFetcher: .init(backend: IdleBackend()), - predictionsFetcher: predictionsFetcher + predictionsFetcher: predictionsFetcher, alertsFetcher: .init(socket: MockSocket()) ) ViewHosting.host(view: sut) @@ -507,7 +509,7 @@ final class NearbyTransitViewTests: XCTestCase { ), nearbyFetcher: nearbyFetcher, scheduleFetcher: .init(backend: IdleBackend()), - predictionsFetcher: predictionsFetcher + predictionsFetcher: predictionsFetcher, alertsFetcher: .init(socket: MockSocket()) ) ViewHosting.host(view: sut) @@ -554,7 +556,7 @@ final class NearbyTransitViewTests: XCTestCase { ), nearbyFetcher: nearbyFetcher, scheduleFetcher: .init(backend: IdleBackend()), - predictionsFetcher: predictionsFetcher + predictionsFetcher: predictionsFetcher, alertsFetcher: .init(socket: MockSocket()) ) ViewHosting.host(view: sut) @@ -584,7 +586,7 @@ final class NearbyTransitViewTests: XCTestCase { ), nearbyFetcher: FakeNearbyFetcher(), scheduleFetcher: .init(backend: IdleBackend()), - predictionsFetcher: .init(socket: MockSocket()) + predictionsFetcher: .init(socket: MockSocket()), alertsFetcher: .init(socket: MockSocket()) ) XCTAssertNotNil(try sut.inspect().view(NearbyTransitView.self).find(text: "Failed to load nearby transit, test error")) @@ -613,7 +615,7 @@ final class NearbyTransitViewTests: XCTestCase { ), nearbyFetcher: FakeNearbyFetcher(), scheduleFetcher: .init(backend: IdleBackend()), - predictionsFetcher: FakePredictionsFetcher() + predictionsFetcher: FakePredictionsFetcher(), alertsFetcher: .init(socket: MockSocket()) ) XCTAssertNotNil(try sut.inspect().view(NearbyTransitView.self).find(text: "Failed to load predictions, test error")) @@ -649,7 +651,8 @@ final class NearbyTransitViewTests: XCTestCase { locationProvider: locationProvider, nearbyFetcher: fakeFetcher, scheduleFetcher: .init(backend: IdleBackend()), - predictionsFetcher: .init(socket: MockSocket()) + predictionsFetcher: .init(socket: MockSocket()), + alertsFetcher: .init(socket: MockSocket()) ) let newLocation = CLLocationCoordinate2D(latitude: 0.0, longitude: 0.0) @@ -681,4 +684,35 @@ final class NearbyTransitViewTests: XCTestCase { let currentProvider: NearbyTransitLocationProvider = .init(currentLocation: currentLocation, cameraLocation: cameraLocation, isFollowing: true) XCTAssertEqual(currentProvider.location, currentLocation) } + + func testNoService() throws { + let scheduleFetcher = ScheduleFetcher(backend: IdleBackend()) + scheduleFetcher.schedules = .init(schedules: [], trips: [:]) + + let predictionsFetcher = PredictionsFetcher(socket: MockSocket()) + predictionsFetcher.predictions = .init(predictions: [:], trips: [:], vehicles: [:]) + + let alertsFetcher = AlertsFetcher(socket: MockSocket()) + let objects = ObjectCollectionBuilder() + objects.alert { alert in + alert.activePeriod(start: Date.now.addingTimeInterval(-1).toKotlinInstant(), end: nil) + alert.effect = .suspension + alert.informedEntity(activities: [.board], directionId: nil, facility: nil, route: "52", routeType: .bus, stop: "8552", trip: nil) + } + alertsFetcher.alerts = AlertsStreamDataResponse(objects: objects) + + let sut = NearbyTransitView( + locationProvider: .init( + currentLocation: CLLocationCoordinate2D(latitude: 12.34, longitude: -56.78), + cameraLocation: ViewportProvider.defaultCenter, + isFollowing: true + ), + nearbyFetcher: Route52NearbyFetcher(), + scheduleFetcher: scheduleFetcher, + predictionsFetcher: predictionsFetcher, + alertsFetcher: alertsFetcher + ) + + XCTAssertNotNil(try sut.inspect().find(text: "Suspension")) + } } diff --git a/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/model/Alert.kt b/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/model/Alert.kt index da7e0e98a..87635a2de 100644 --- a/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/model/Alert.kt +++ b/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/model/Alert.kt @@ -72,6 +72,25 @@ data class Alert( @SerialName("using_escalator") UsingEscalator, @SerialName("using_wheelchair") UsingWheelchair, } + + fun appliesTo( + directionId: Int? = null, + facilityId: String? = null, + routeId: String? = null, + routeType: RouteType? = null, + stopId: String? = null, + tripId: String? = null + ): Boolean { + fun matches(expected: T?, actual: T?) = + expected == null || actual == null || expected == actual + + return matches(directionId, this.directionId) && + matches(facilityId, this.facility) && + matches(routeId, this.route) && + matches(routeType, this.routeType) && + matches(stopId, this.stop) && + matches(tripId, this.trip) + } } @Serializable @@ -81,4 +100,9 @@ data class Alert( @SerialName("ongoing_upcoming") OngoingUpcoming, @SerialName("upcoming") Upcoming, } + + fun isActive(time: Instant) = + activePeriod.any { it.start <= time && (it.end == null || it.end >= time) } + + fun anyInformedEntity(predicate: (InformedEntity) -> Boolean) = informedEntity.any(predicate) } diff --git a/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/model/ObjectCollectionBuilder.kt b/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/model/ObjectCollectionBuilder.kt index 763c0ef5a..3d1d054b8 100644 --- a/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/model/ObjectCollectionBuilder.kt +++ b/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/model/ObjectCollectionBuilder.kt @@ -18,6 +18,7 @@ import kotlinx.datetime.Instant * patterns from routes) are built with a required parent argument */ class ObjectCollectionBuilder { + val alerts = mutableMapOf() val predictions = mutableMapOf() val routes = mutableMapOf() val routePatterns = mutableMapOf() @@ -30,6 +31,46 @@ class ObjectCollectionBuilder { fun built(): Built } + class AlertBuilder : ObjectBuilder { + var id = uuid() + var activePeriod = mutableListOf() + var effect = Alert.Effect.UnknownEffect + var effectName: String? = null + var informedEntity = mutableListOf() + var lifecycle = Alert.Lifecycle.New + + fun activePeriod(start: Instant, end: Instant?) { + activePeriod.add(Alert.ActivePeriod(start, end)) + } + + fun informedEntity( + activities: List, + directionId: Int? = null, + facility: String? = null, + route: String? = null, + routeType: RouteType? = null, + stop: String? = null, + trip: String? = null, + ) { + informedEntity.add( + Alert.InformedEntity( + activities, + directionId, + facility, + route, + routeType, + stop, + trip + ) + ) + } + + override fun built() = + Alert(id, activePeriod, effect, effectName, informedEntity, lifecycle) + } + + fun alert(block: AlertBuilder.() -> Unit) = build(alerts, AlertBuilder(), block) + inner class PredictionBuilder : ObjectBuilder { var id = uuid() var arrivalTime: Instant? = null @@ -241,6 +282,8 @@ class ObjectCollectionBuilder { } object Single { + fun alert(block: AlertBuilder.() -> Unit = {}) = ObjectCollectionBuilder().alert(block) + fun prediction(block: PredictionBuilder.() -> Unit = {}) = ObjectCollectionBuilder().prediction(block) diff --git a/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/model/StopAssociatedRoute.kt b/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/model/StopAssociatedRoute.kt index 4f4ff63a8..38322478f 100644 --- a/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/model/StopAssociatedRoute.kt +++ b/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/model/StopAssociatedRoute.kt @@ -1,5 +1,6 @@ package com.mbta.tid.mbta_app.model +import com.mbta.tid.mbta_app.model.response.AlertsStreamDataResponse import com.mbta.tid.mbta_app.model.response.PredictionsStreamDataResponse import com.mbta.tid.mbta_app.model.response.ScheduleResponse import io.github.dellisd.spatialk.geojson.Position @@ -21,12 +22,14 @@ data class PatternsByHeadsign( val headsign: String, val patterns: List, val upcomingTrips: List? = null, + val alertsHere: List? = null, ) : Comparable { constructor( staticData: NearbyStaticData.HeadsignWithPatterns, routeId: String, upcomingTripsMap: UpcomingTripsMap?, - stopIds: Set + stopIds: Set, + alerts: Collection?, ) : this( staticData.headsign, staticData.patterns, @@ -39,6 +42,18 @@ data class PatternsByHeadsign( .sorted() } else { null + }, + if (alerts != null) { + stopIds.flatMap { stopId -> + alerts.filter { alert -> + alert.anyInformedEntity { + it.appliesTo(routeId = routeId, stopId = stopId) && + it.activities.contains(Alert.InformedEntity.Activity.Board) + } + } + } + } else { + null } ) @@ -95,12 +110,13 @@ data class PatternsByStop(val stop: Stop, val patternsByHeadsign: List?, ) : this( staticData.stop, staticData.patternsByHeadsign .map { - PatternsByHeadsign(it, routeId, upcomingTripsMap, stopIds = staticData.allStopIds) + PatternsByHeadsign(it, routeId, upcomingTripsMap, staticData.allStopIds, alerts) } .filter { (it.isTypical() || it.isUpcomingBefore(cutoffTime)) && !it.isArrivalOnly() } .sorted() @@ -121,11 +137,12 @@ data class StopAssociatedRoute( staticData: NearbyStaticData.RouteWithStops, upcomingTripsMap: UpcomingTripsMap?, cutoffTime: Instant, - sortByDistanceFrom: Position + sortByDistanceFrom: Position, + alerts: Collection?, ) : this( staticData.route, staticData.patternsByStop - .map { PatternsByStop(it, staticData.route.id, upcomingTripsMap, cutoffTime) } + .map { PatternsByStop(it, staticData.route.id, upcomingTripsMap, cutoffTime, alerts) } .filterNot { it.patternsByHeadsign.isEmpty() } .sortedWith( compareBy( @@ -149,6 +166,7 @@ fun NearbyStaticData.withRealtimeInfo( sortByDistanceFrom: Position, schedules: ScheduleResponse?, predictions: PredictionsStreamDataResponse?, + alerts: AlertsStreamDataResponse?, filterAtTime: Instant ): List { // add predictions and apply filtering @@ -183,9 +201,28 @@ fun NearbyStaticData.withRealtimeInfo( } val cutoffTime = filterAtTime.plus(90.minutes) + val activeRelevantAlerts = + alerts?.alerts?.values?.filter { + it.isActive(filterAtTime) && + setOf( + Alert.Effect.StationClosure, + Alert.Effect.Shuttle, + Alert.Effect.Suspension, + Alert.Effect.Detour, + Alert.Effect.StopClosure + ) + .contains(it.effect) + } + return data .map { - StopAssociatedRoute(it, upcomingTripsByHeadsignAndStop, cutoffTime, sortByDistanceFrom) + StopAssociatedRoute( + it, + upcomingTripsByHeadsignAndStop, + cutoffTime, + sortByDistanceFrom, + activeRelevantAlerts + ) } .filterNot { it.patternsByStop.isEmpty() } .sortedWith(compareBy({ it.distanceFrom(sortByDistanceFrom) }, { it.route })) diff --git a/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/model/response/AlertsStreamDataResponse.kt b/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/model/response/AlertsStreamDataResponse.kt index 36d77ce1f..185553f1a 100644 --- a/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/model/response/AlertsStreamDataResponse.kt +++ b/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/model/response/AlertsStreamDataResponse.kt @@ -1,6 +1,10 @@ package com.mbta.tid.mbta_app.model.response import com.mbta.tid.mbta_app.model.Alert +import com.mbta.tid.mbta_app.model.ObjectCollectionBuilder import kotlinx.serialization.Serializable -@Serializable data class AlertsStreamDataResponse(val alerts: Map) +@Serializable +data class AlertsStreamDataResponse(val alerts: Map) { + constructor(objects: ObjectCollectionBuilder) : this(objects.alerts) +} diff --git a/shared/src/commonTest/kotlin/com/mbta/tid/mbta_app/model/NearbyResponseTest.kt b/shared/src/commonTest/kotlin/com/mbta/tid/mbta_app/model/NearbyResponseTest.kt index 08575b8fd..1b2fea2c5 100644 --- a/shared/src/commonTest/kotlin/com/mbta/tid/mbta_app/model/NearbyResponseTest.kt +++ b/shared/src/commonTest/kotlin/com/mbta/tid/mbta_app/model/NearbyResponseTest.kt @@ -1,5 +1,6 @@ package com.mbta.tid.mbta_app.model +import com.mbta.tid.mbta_app.model.response.AlertsStreamDataResponse import com.mbta.tid.mbta_app.model.response.PredictionsStreamDataResponse import com.mbta.tid.mbta_app.model.response.ScheduleResponse import com.mbta.tid.mbta_app.model.response.StopAndRoutePatternResponse @@ -475,7 +476,8 @@ class NearbyResponseTest { sortByDistanceFrom = stop1.position, schedules = null, predictions = PredictionsStreamDataResponse(objects), - filterAtTime = time + filterAtTime = time, + alerts = null, ) ) } @@ -619,7 +621,8 @@ class NearbyResponseTest { sortByDistanceFrom = stop1.position, schedules = null, predictions = PredictionsStreamDataResponse(objects), - filterAtTime = time + filterAtTime = time, + alerts = null, ) ) } @@ -716,6 +719,7 @@ class NearbyResponseTest { sortByDistanceFrom = stop1.position, schedules = null, predictions = null, + alerts = null, filterAtTime = time ) ) @@ -828,6 +832,7 @@ class NearbyResponseTest { predictions = PredictionsStreamDataResponse(objects), filterAtTime = time, schedules = ScheduleResponse(objects), + alerts = null, ) assertEquals( listOf(closeSubwayRoute, farSubwayRoute, closeBusRoute, farBusRoute), @@ -882,7 +887,8 @@ class NearbyResponseTest { sortByDistanceFrom = parentStop.position, schedules = null, predictions = PredictionsStreamDataResponse(objects), - filterAtTime = time + filterAtTime = time, + alerts = null, ) ) } @@ -943,7 +949,8 @@ class NearbyResponseTest { sortByDistanceFrom = stop.position, schedules = ScheduleResponse(objects), predictions = PredictionsStreamDataResponse(objects), - filterAtTime = time + filterAtTime = time, + alerts = null, ) ) } @@ -1023,7 +1030,74 @@ class NearbyResponseTest { sortByDistanceFrom = stop.position, schedules = ScheduleResponse(objects), predictions = PredictionsStreamDataResponse(objects), - filterAtTime = time + filterAtTime = time, + alerts = null, + ) + ) + } + + @Test + fun `withRealtimeInfo picks out alerts`() { + val objects = ObjectCollectionBuilder() + val stop = objects.stop() + val route = objects.route { sortOrder = 1 } + val routePattern = + objects.routePattern(route) { + typicality = RoutePattern.Typicality.Typical + representativeTrip { headsign = "A" } + } + + val time = Instant.parse("2024-03-19T14:16:17-04:00") + + val alert = + objects.alert { + activePeriod( + Instant.parse("2024-03-18T04:30:00-04:00"), + Instant.parse("2024-03-22T02:30:00-04:00") + ) + effect = Alert.Effect.Suspension + informedEntity( + listOf( + Alert.InformedEntity.Activity.Board, + Alert.InformedEntity.Activity.Exit, + Alert.InformedEntity.Activity.Ride + ), + route = route.id, + routeType = route.type, + stop = stop.id + ) + } + + val staticData = + NearbyStaticData.build { + route(route) { stop(stop) { headsign("A", listOf(routePattern)) } } + } + + assertEquals( + listOf( + StopAssociatedRoute( + route, + listOf( + PatternsByStop( + stop, + listOf( + PatternsByHeadsign( + "A", + listOf(routePattern), + emptyList(), + alertsHere = listOf(alert) + ) + ) + ) + ) + ) + ), + staticData.withRealtimeInfo( + sortByDistanceFrom = stop.position, + schedules = ScheduleResponse(objects), + predictions = PredictionsStreamDataResponse(objects), + filterAtTime = time, + alerts = AlertsStreamDataResponse(objects), ) ) } @@ -1090,6 +1164,7 @@ class NearbyResponseTest { sortByDistanceFrom = stop.position, schedules = ScheduleResponse(objects), predictions = PredictionsStreamDataResponse(objects), + alerts = null, filterAtTime = time ) )