diff --git a/iosApp/iosApp/Pages/Map/StopIcons.swift b/iosApp/iosApp/Pages/Map/StopIcons.swift index 979f97f20..734862cb9 100644 --- a/iosApp/iosApp/Pages/Map/StopIcons.swift +++ b/iosApp/iosApp/Pages/Map/StopIcons.swift @@ -32,9 +32,9 @@ enum StopIcons { .expression( Exp(.match) { Exp(.get) { StopSourceGenerator.propServiceStatusKey } - String(describing: StopSourceServiceStatus.noService) + String(describing: StopServiceStatus.noService) stationIconNoServiceId - String(describing: StopSourceServiceStatus.disrupted) + String(describing: StopServiceStatus.partialService) stationIconIssuesId stationIconId } @@ -46,9 +46,9 @@ enum StopIcons { tombstoneZoomThreshold Exp(.match) { Exp(.get) { StopSourceGenerator.propServiceStatusKey } - String(describing: StopSourceServiceStatus.noService) + String(describing: StopServiceStatus.noService) stopIconNoServiceId - String(describing: StopSourceServiceStatus.disrupted) + String(describing: StopServiceStatus.partialService) stopIconIssuesId stopIconId } diff --git a/iosApp/iosApp/Pages/Map/StopSourceGenerator.swift b/iosApp/iosApp/Pages/Map/StopSourceGenerator.swift index 9a67b8ef4..d6cd4d0a8 100644 --- a/iosApp/iosApp/Pages/Map/StopSourceGenerator.swift +++ b/iosApp/iosApp/Pages/Map/StopSourceGenerator.swift @@ -15,12 +15,6 @@ struct StopFeatureData { let feature: Feature } -enum StopSourceServiceStatus { - case normal - case disrupted - case noService -} - class StopSourceGenerator { let stops: [String: Stop] let routeSourceDetails: [RouteSourceData]? @@ -105,13 +99,7 @@ class StopSourceGenerator { } } - func getServiceStatus(stop: Stop) -> StopSourceServiceStatus { - guard let alertsAtStop = alertsByStop[stop.id] else { return StopSourceServiceStatus.normal } - if alertsAtStop.hasNoService { - return StopSourceServiceStatus.noService - } else if alertsAtStop.hasSomeDisruptedService { - return StopSourceServiceStatus.disrupted - } - return StopSourceServiceStatus.normal + func getServiceStatus(stop: Stop) -> StopServiceStatus { + alertsByStop[stop.id]?.serviceStatus ?? StopServiceStatus.normal } } diff --git a/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/model/AlertAssociatedStop.kt b/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/model/AlertAssociatedStop.kt index 3b87a7a2f..df845fa32 100644 --- a/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/model/AlertAssociatedStop.kt +++ b/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/model/AlertAssociatedStop.kt @@ -1,48 +1,103 @@ package com.mbta.tid.mbta_app.model -private val disruptableStopTypes: List = - listOf(LocationType.STOP, LocationType.STATION) - class AlertAssociatedStop( val stop: Stop, - val relevantAlerts: Set, - val routePatterns: List, - val childStops: Map, + val relevantAlerts: List, + val serviceAlerts: List, val childAlerts: Map, + val serviceStatus: StopServiceStatus ) { - val serviceAlerts = relevantAlerts.filter { Alert.serviceDisruptionEffects.contains(it.effect) } + constructor( + stop: Stop, + relevantAlerts: List, + routePatterns: List, + childStops: Map, + childAlerts: Map + ) : this( + stop, + relevantAlerts, + getServiceAlerts(relevantAlerts), + childAlerts, + getServiceStatus( + stop, + getServiceAlerts(relevantAlerts), + routePatterns, + childStops, + childAlerts + ) + ) +} + +enum class StopServiceStatus { + NORMAL, + NO_SERVICE, + PARTIAL_SERVICE +} + +private fun entityMatcher( + entity: Alert.InformedEntity, + stop: Stop, + pattern: RoutePattern +): Boolean { + return entity.appliesTo( + stopId = stop.id, + routeId = pattern.routeId, + directionId = pattern.directionId + ) +} + +private fun getDisruptableChildren(childStops: Map): List { + return childStops.values.filter { + listOf(LocationType.STOP, LocationType.STATION).contains(it.locationType) + } +} - var hasNoService: Boolean = +private fun getServiceAlerts(alerts: List): List { + return alerts.filter { Alert.serviceDisruptionEffects.contains(it.effect) } +} + +private fun getServiceStatus( + stop: Stop, + serviceAlerts: List, + routePatterns: List, + childStops: Map, + childAlerts: Map +): StopServiceStatus { + val children = getDisruptableChildren(childStops) + + val hasNoService = if (routePatterns.isEmpty()) { // No route patterns and every child station/stop has no service - childStops.isNotEmpty() && - childStops.values - .filter { disruptableStopTypes.contains(it.locationType) } - .all { childAlerts[it.id]?.hasNoService == true } + childStops.isNotEmpty() && children.all { hasNoService(it, childAlerts) } } else { // All route patterns and child stations/stops have no service - routePatterns.all { pattern -> - serviceAlerts.any { alert -> - alert.anyInformedEntity { entityMatcher(it, stop, pattern) } - } - } && - childStops.values - .filter { disruptableStopTypes.contains(it.locationType) } - .all { childAlerts[it.id]?.hasNoService == true } + routePatterns.all { isDisruptedPattern(it, stop, serviceAlerts) } && + children.all { hasNoService(it, childAlerts) } } + if (hasNoService) { + return StopServiceStatus.NO_SERVICE + } + + val hasSomeDisruptedService: Boolean = + routePatterns.any { isDisruptedPattern(it, stop, serviceAlerts) } || + children.any { hasNoService(it, childAlerts) } + if (hasSomeDisruptedService) { + return StopServiceStatus.PARTIAL_SERVICE + } - var hasSomeDisruptedService: Boolean = - routePatterns.any { pattern -> - serviceAlerts.any { alert -> - alert.anyInformedEntity { entityMatcher(it, stop, pattern) } - } - } || stop.childStopIds?.any { childAlerts[it]?.hasSomeDisruptedService == true } == true + return StopServiceStatus.NORMAL +} - private fun entityMatcher( - entity: Alert.InformedEntity, - stop: Stop, - pattern: RoutePattern - ): Boolean { - return entity.appliesTo(stopId = stop.id, routeId = pattern.routeId) +private fun hasNoService(stop: Stop, alerts: Map): Boolean { + return alerts[stop.id]?.serviceStatus == StopServiceStatus.NO_SERVICE +} + +private fun isDisruptedPattern( + pattern: RoutePattern, + stop: Stop, + serviceAlerts: List +): Boolean { + return serviceAlerts.any { alert -> + alert.anyInformedEntity { entityMatcher(it, stop, pattern) } } } diff --git a/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/model/GlobalStaticData.kt b/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/model/GlobalStaticData.kt index 5c6b37bec..c6b62b669 100644 --- a/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/model/GlobalStaticData.kt +++ b/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/model/GlobalStaticData.kt @@ -5,6 +5,12 @@ import com.mbta.tid.mbta_app.model.response.GlobalResponse import kotlinx.datetime.Instant data class GlobalStaticData(val globalData: GlobalResponse) { + + /* + Only stops that don't have a parent stop (stations and isolated stops) are returned in the map. + Each AlertAssociatedStop will have entries in childAlerts if there are any active alerts on + their children, but those child alerts aren't included in the map returned by this function. + */ fun withRealtimeAlertsByStop( alerts: AlertsStreamDataResponse?, filterAtTime: Instant @@ -45,20 +51,18 @@ data class GlobalStaticData(val globalData: GlobalResponse) { val alertingStop = AlertAssociatedStop( stop = stop, - relevantAlerts = alertsByStop[stop.id]?.toSet() ?: emptySet(), + relevantAlerts = alertsByStop[stop.id]?.toList() ?: emptyList(), routePatterns = getRoutePatternsFor(stop.id), childStops = stop.childStopIds - ?.mapNotNull { childId -> globalData.stops[childId] } - ?.associateBy { it.id } - ?: emptyMap(), + .mapNotNull { childId -> globalData.stops[childId] } + .associateBy { it.id }, childAlerts = stop.childStopIds - ?.mapNotNull { childId -> + .mapNotNull { childId -> generateAlertingStopFor(globalData.stops[childId], alertsByStop) } - ?.associateBy { it.stop.id } - ?: emptyMap() + .associateBy { it.stop.id } ) // Return null for any stops without alerts or child alerts 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 f712614fb..7dcf5f3e7 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 @@ -264,7 +264,7 @@ class ObjectCollectionBuilder { var name = "" var locationType = LocationType.STOP var parentStationId: String? = null - var childStopIds: List? = emptyList() + var childStopIds: List = emptyList() override fun built() = Stop(id, latitude, longitude, name, locationType, parentStationId, childStopIds) diff --git a/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/model/Stop.kt b/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/model/Stop.kt index 1ced6cd84..ddd787eb7 100644 --- a/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/model/Stop.kt +++ b/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/model/Stop.kt @@ -12,7 +12,7 @@ data class Stop( val name: String, @SerialName("location_type") val locationType: LocationType, @SerialName("parent_station_id") val parentStationId: String? = null, - @SerialName("child_stop_ids") val childStopIds: List? = null + @SerialName("child_stop_ids") val childStopIds: List = emptyList() ) : BackendObject { val position = Position(latitude = latitude, longitude = longitude) } diff --git a/shared/src/commonTest/kotlin/com/mbta/tid/mbta_app/model/GlobalResponseTest.kt b/shared/src/commonTest/kotlin/com/mbta/tid/mbta_app/model/GlobalResponseTest.kt index 0b6ec07b6..f0adbd484 100644 --- a/shared/src/commonTest/kotlin/com/mbta/tid/mbta_app/model/GlobalResponseTest.kt +++ b/shared/src/commonTest/kotlin/com/mbta/tid/mbta_app/model/GlobalResponseTest.kt @@ -4,7 +4,6 @@ import com.mbta.tid.mbta_app.model.response.AlertsStreamDataResponse import com.mbta.tid.mbta_app.model.response.GlobalResponse import kotlin.test.Test import kotlin.test.assertEquals -import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue @@ -50,9 +49,8 @@ class GlobalResponseTest { val alertingStop = alertsByStop?.get(stop.id) assertNotNull(alertingStop) - assertEquals(alertingStop.serviceAlerts, listOf(alert)) - assertTrue(alertingStop.hasNoService) - assertTrue(alertingStop.hasSomeDisruptedService) + assertEquals(listOf(alert), alertingStop.serviceAlerts) + assertEquals(StopServiceStatus.NO_SERVICE, alertingStop.serviceStatus) } @Test @@ -105,14 +103,8 @@ class GlobalResponseTest { val childStop = objects.stop { id = "child" - childStopIds = listOf("nestedChild") parentStationId = parentStop.id } - val nestedChildStop = - objects.stop { - id = "nestedChild" - parentStationId = childStop.id - } val route = objects.route { id = "route" } val routePattern = objects.routePattern(route) { id = "rp1" } @@ -133,14 +125,14 @@ class GlobalResponseTest { ), route = route.id, routeType = route.type, - stop = nestedChildStop.id + stop = childStop.id ) } val globalResponse = GlobalResponse( objects = objects, - patternIdsByStop = mapOf(Pair(nestedChildStop.id, listOf(routePattern.id))) + patternIdsByStop = mapOf(Pair(childStop.id, listOf(routePattern.id))) ) val staticData = GlobalStaticData(globalData = globalResponse) val alertsByStop = @@ -149,14 +141,12 @@ class GlobalResponseTest { val alertingParent = alertsByStop?.get(parentStop.id) assertNotNull(alertingParent) assertTrue(alertingParent.relevantAlerts.isEmpty()) - assertTrue(alertingParent.hasNoService) - assertTrue(alertingParent.hasSomeDisruptedService) + assertEquals(StopServiceStatus.NO_SERVICE, alertingParent.serviceStatus) val childAlert = alertingParent.childAlerts[childStop.id] - val nestedChildAlert = childAlert?.childAlerts?.get(nestedChildStop.id) - assertNotNull(nestedChildAlert) - assertTrue(nestedChildAlert.hasNoService) - assertEquals(nestedChildAlert.serviceAlerts, listOf(alert)) + assertNotNull(childAlert) + assertEquals(StopServiceStatus.NO_SERVICE, childAlert.serviceStatus) + assertEquals(listOf(alert), childAlert.serviceAlerts) } @Test @@ -165,23 +155,17 @@ class GlobalResponseTest { val parentStop = objects.stop { id = "parent" - childStopIds = listOf("child") + childStopIds = listOf("child1", "child2") } - val childStop = + val childStop1 = objects.stop { - id = "child" - childStopIds = listOf("nestedChild1", "nestedChild2") + id = "child1" parentStationId = parentStop.id } - val nestedChildStop1 = - objects.stop { - id = "nestedChild1" - parentStationId = childStop.id - } - val nestedChildStop2 = + val childStop2 = objects.stop { - id = "nestedChild2" - parentStationId = childStop.id + id = "child2" + parentStationId = parentStop.id } val route = objects.route { id = "route" } val routePattern1 = objects.routePattern(route) { id = "rp1" } @@ -204,7 +188,7 @@ class GlobalResponseTest { ), route = route.id, routeType = route.type, - stop = nestedChildStop1.id + stop = childStop1.id ) } val alert2 = @@ -218,7 +202,7 @@ class GlobalResponseTest { listOf(), route = route.id, routeType = route.type, - stop = nestedChildStop2.id + stop = childStop2.id ) } @@ -227,8 +211,8 @@ class GlobalResponseTest { objects = objects, patternIdsByStop = mapOf( - Pair(nestedChildStop1.id, listOf(routePattern1.id)), - Pair(nestedChildStop2.id, listOf(routePattern2.id)) + Pair(childStop1.id, listOf(routePattern1.id)), + Pair(childStop2.id, listOf(routePattern2.id)) ) ) val staticData = GlobalStaticData(globalData = globalResponse) @@ -238,21 +222,17 @@ class GlobalResponseTest { val alertingParent = alertsByStop?.get(parentStop.id) assertNotNull(alertingParent) assertTrue(alertingParent.relevantAlerts.isEmpty()) - assertFalse(alertingParent.hasNoService) - assertTrue(alertingParent.hasSomeDisruptedService) - - val childAlert = alertingParent.childAlerts[childStop.id] - assertNotNull(childAlert) - - val nestedChild1Alert = childAlert.childAlerts[nestedChildStop1.id] - assertNotNull(nestedChild1Alert) - assertTrue(nestedChild1Alert.hasNoService) - assertEquals(nestedChild1Alert.serviceAlerts, listOf(alert1)) - - val nestedChild2Alert = childAlert.childAlerts[nestedChildStop2.id] - assertNotNull(nestedChild2Alert) - assertFalse(nestedChild2Alert.hasNoService) - assertEquals(nestedChild2Alert.serviceAlerts, emptyList()) - assertEquals(nestedChild2Alert.relevantAlerts, setOf(alert2)) + assertEquals(StopServiceStatus.PARTIAL_SERVICE, alertingParent.serviceStatus) + + val child1Alert = alertingParent.childAlerts[childStop1.id] + assertNotNull(child1Alert) + assertEquals(StopServiceStatus.NO_SERVICE, child1Alert.serviceStatus) + assertEquals(listOf(alert1), child1Alert.serviceAlerts) + + val child2Alert = alertingParent.childAlerts[childStop2.id] + assertNotNull(child2Alert) + assertEquals(StopServiceStatus.NORMAL, child2Alert.serviceStatus) + assertEquals(emptyList(), child2Alert.serviceAlerts) + assertEquals(listOf(alert2), child2Alert.relevantAlerts) } }