Christopher Hotchkiss Christopher Hotchkiss
Login

Code

remindwall / RWPureSwift / Sources / Dashboard / TrafficAlerts.swift

Back to remindwall — Current RemindWall

Download this file

import AppTypes
import ComposableArchitecture
import Dao
import Dependencies
import Foundation
import SQLiteData
import Tagged
import TrafficAPI

public struct DisplayRoute: Equatable, Identifiable, Sendable {
    public let id: MonitoredRoute.ID
    public let label: String
    public let etaMinutes: Int
    public let normalMinutes: Int
    public let etaSeconds: TimeInterval
    public let isLate: Bool
}

@Reducer
public struct TrafficAlertsFeature: Sendable {
    @Dependency(\.trafficAPI) var trafficAPI
    @Dependency(\.continuousClock) var clock
    @Dependency(\.date.now) var now
    @Dependency(\.calendar) var calendar

    /// Traffic shifts over minutes, not seconds - and MKDirections is not a
    /// firehose to point a 30s timer at.
    static let refreshInterval = Duration.seconds(60 * 5)
    /// Late when the live ETA exceeds the route's normal by this much.
    static let lateThresholdMinutes = 5

    @ObservableState
    public struct State: Equatable {
        @Shared(.syncedSetting(HOME_ORIGIN_SETTING_KEY)) public var homeOrigin: HomeOrigin?

        @FetchAll(MonitoredRoute.none)
        public var routes: [MonitoredRoute]

        public var displayRoutes: [DisplayRoute] = []
        public var lastError: String? = nil

        /// One card per route; a fetch failure with nothing to show collapses
        /// to a single error chip, mirroring the bus rules (stale beats noise).
        public var railCards: [RailCard] {
            var cards = displayRoutes.map { route in
                RailCard(
                    id: "drive-\(route.id.rawValue.uuidString)",
                    priority: route.isLate ? .slowRoute : .onTimeRoute,
                    etaSeconds: route.etaSeconds,
                    content: .drive(
                        label: route.label,
                        etaText: "\(route.etaMinutes) min",
                        isLate: route.isLate
                    )
                )
            }
            if displayRoutes.isEmpty, let lastError {
                cards.append(RailCard(
                    id: "drive-error",
                    priority: .errorChip,
                    content: .errorChip(message: "Drive times unavailable: \(lastError)")
                ))
            }
            return cards
        }

        public init() {
            self._routes = FetchAll(MonitoredRoute.all.order(by: \.sortOrder))
        }
    }

    public enum Action: Equatable {
        case startMonitoring
        case tick
        case _routesLoaded([DisplayRoute], error: String?)
    }

    enum CancelID { case trafficLoop, fetch }

    public init() {}

    public var body: some Reducer<State, Action> {
        Reduce { state, action in
            switch action {
            case .startMonitoring:
                return .run { send in
                    await send(.tick)
                    for await _ in clock.timer(interval: Self.refreshInterval) {
                        await send(.tick)
                    }
                }
                .cancellable(id: CancelID.trafficLoop, cancelInFlight: true)

            case .tick:
                // Per-watch gating (TR1.7): each route carries its own window
                // and toggle; nil window means the mode default.
                let active = state.routes.filter { route in
                    route.enabled
                        && (route.window ?? .default).isInWindow(date: now, calendar: calendar)
                }
                guard !active.isEmpty else {
                    return .send(._routesLoaded([], error: nil))
                }
                guard
                    let origin = state.homeOrigin,
                    let latitude = origin.latitude,
                    let longitude = origin.longitude
                else {
                    // Routes exist but no origin: TR1.4's Setup screen is the
                    // fix; until then say so instead of a blank rail.
                    return .send(._routesLoaded([], error: "home origin not set"))
                }
                let originPoint = RoutePoint(latitude: latitude, longitude: longitude)
                return .run { [trafficAPI] send in
                    do {
                        var display: [DisplayRoute] = []
                        for route in active {
                            let eta = try await trafficAPI.calculateETA(
                                originPoint,
                                RoutePoint(
                                    latitude: route.destinationLatitude,
                                    longitude: route.destinationLongitude
                                )
                            )
                            display.append(makeDisplayRoute(route: route, eta: eta))
                        }
                        await send(._routesLoaded(display, error: nil))
                    } catch {
                        await send(._routesLoaded([], error: "\(error)"))
                    }
                }
                .cancellable(id: CancelID.fetch, cancelInFlight: true)

            case let ._routesLoaded(display, error):
                state.displayRoutes = display
                state.lastError = error
                return .none
            }
        }
    }
}

func makeDisplayRoute(route: MonitoredRoute, eta: DriveETA) -> DisplayRoute {
    let minutes = eta.expectedMinutes
    return DisplayRoute(
        id: route.id,
        label: route.label,
        etaMinutes: minutes,
        normalMinutes: route.normalMinutes,
        etaSeconds: eta.expectedTravelTime,
        isLate: minutes > route.normalMinutes + TrafficAlertsFeature.lateThresholdMinutes
    )
}