Christopher Hotchkiss Christopher Hotchkiss
Login

Code

remindwall / RWPureSwift / Sources / Dashboard / BusArrivals.swift

Back to remindwall — Current RemindWall

Download this file

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

public struct DisplayArrival: Equatable, Identifiable, Sendable {
    public let id: MonitoredStop.ID
    public let label: String
    public let routeShortName: String
    public let etaText: String
    public let etaSeconds: TimeInterval
    public let isLate: Bool
    public let isLive: Bool
}

@Reducer
public struct BusArrivalsFeature: Sendable {
    @Dependency(\.transitAPI) var transitAPI
    @Dependency(\.transitKeyStore) var transitKeyStore
    @Dependency(\.continuousClock) var clock
    @Dependency(\.date.now) var now
    @Dependency(\.calendar) var calendar

    static let refreshInterval = Duration.seconds(30)
    static let lateThresholdSeconds: TimeInterval = 90

    @ObservableState
    public struct State: Equatable {
        @FetchAll(MonitoredStop.none)
        public var monitoredStops: [MonitoredStop]

        public var arrivals: [DisplayArrival] = []
        public var lastError: String? = nil

        /// One card per monitored stop; a fetch failure with nothing to show
        /// collapses to a single error chip (lowest priority - family info,
        /// not devops).
        public var railCards: [RailCard] {
            var cards = arrivals.map { arrival in
                RailCard(
                    id: "bus-\(arrival.id.rawValue.uuidString)",
                    priority: arrival.isLate ? .lateBus : .onTimeBus,
                    etaSeconds: arrival.etaSeconds,
                    content: .transit(
                        route: arrival.routeShortName,
                        label: arrival.label,
                        etaText: arrival.etaText,
                        isLate: arrival.isLate,
                        isLive: arrival.isLive
                    )
                )
            }
            if arrivals.isEmpty, let lastError {
                cards.append(RailCard(
                    id: "bus-error",
                    priority: .errorChip,
                    content: .errorChip(message: "Cannot reach transit API: \(lastError)")
                ))
            }
            return cards
        }

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

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

    enum CancelID { case busLoop }

    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.busLoop, cancelInFlight: true)

            case .tick:
                // Per-watch gating (TR1.7): each stop carries its own window
                // and toggle; nil window means the mode default.
                let active = state.monitoredStops.filter { stop in
                    stop.enabled
                        && (stop.window ?? .default).isInWindow(date: now, calendar: calendar)
                }
                guard let key = transitKeyStore.read(), !active.isEmpty else {
                    return .send(._arrivalsLoaded([], error: nil))
                }
                let uniqueStopIds = Array(Set(active.map(\.stopId)))
                return .run { [transitAPI, now] send in
                    do {
                        let byStop = try await fetchAllArrivals(
                            api: transitAPI, key: key, stopIds: uniqueStopIds
                        )
                        let display = active.compactMap { stop -> DisplayArrival? in
                            let arrivals = (byStop[stop.stopId] ?? [])
                                .filter { $0.routeId == stop.routeId }
                                .sorted {
                                    ($0.effectiveArrival ?? .distantFuture)
                                        < ($1.effectiveArrival ?? .distantFuture)
                                }
                            return makeDisplay(stop: stop, soonest: arrivals.first, now: now)
                        }
                        await send(._arrivalsLoaded(display, error: nil))
                    } catch {
                        await send(._arrivalsLoaded([], error: "\(error)"))
                    }
                }

            case let ._arrivalsLoaded(arrivals, error):
                state.arrivals = arrivals
                state.lastError = error
                return .none
            }
        }
    }
}

private func fetchAllArrivals(
    api: TransitAPIClient,
    key: String,
    stopIds: [String]
) async throws -> [String: [ArrivalPrediction]] {
    try await withThrowingTaskGroup(of: (String, [ArrivalPrediction]).self) { group in
        for stopId in stopIds {
            group.addTask {
                let arrivals = try await api.fetchArrivals(apiKey: key, stopId: stopId)
                return (stopId, arrivals)
            }
        }
        var result: [String: [ArrivalPrediction]] = [:]
        for try await (stopId, arrivals) in group {
            result[stopId] = arrivals
        }
        return result
    }
}

func makeDisplay(
    stop: MonitoredStop,
    soonest: ArrivalPrediction?,
    now: Date
) -> DisplayArrival? {
    guard let arrival = soonest, let when = arrival.effectiveArrival else {
        return nil
    }
    let formatter = DateComponentsFormatter()
    formatter.unitsStyle = .brief
    formatter.allowedUnits = [.hour, .minute]
    let etaCore = formatter.string(from: now, to: when) ?? ""
    let etaText: String
    if arrival.isLive {
        etaText = etaCore.isEmpty ? "now" : etaCore
    } else {
        etaText = etaCore.isEmpty ? "scheduled" : "scheduled \(etaCore)"
    }
    let isLate: Bool = {
        guard let lateness = arrival.lateness else { return false }
        return lateness > BusArrivalsFeature.lateThresholdSeconds
    }()
    return DisplayArrival(
        id: stop.id,
        label: stop.label,
        routeShortName: stop.routeShortName,
        etaText: etaText,
        etaSeconds: when.timeIntervalSince(now),
        isLate: isLate,
        isLive: arrival.isLive
    )
}