// // MainService.swift // IceGlass // // Copyright 2026 Rouslan Zenetl. All Rights Reserved. // import Foundation /// Platform-agnostic callback so MainService doesn't import AppKit/UIKit. /// macOS sets this to drive MenuManager + StatusItemManager + NotificationManager; /// iOS sets it to invalidate its @Observable view model. @MainActor protocol MainServiceObserver: AnyObject { /// Fired after every successful scoreboard / standings / bracket update. func mainServiceDidUpdate() /// Fired when a future game transitions to live. macOS shows a notification; /// iOS v1 ignores (no notifications). func mainServiceDidDetectGameStart(_ game: Scoreboard.Game) /// Fired when a goal is detected. `scorer` may be nil if play-by-play hasn't /// caught up yet. func mainServiceDidDetectGoal( _ game: Scoreboard.Game, scoringTeam: Scoreboard.Game.Team, scorer: GoalScorer? ) /// Fired when a game transitions to a final state (OVER/FINAL/OFF). func mainServiceDidDetectGameEnd(_ game: Scoreboard.Game) } extension MainServiceObserver { func mainServiceDidDetectGameStart(_: Scoreboard.Game) {} func mainServiceDidDetectGoal(_: Scoreboard.Game, scoringTeam: Scoreboard.Game.Team, scorer: GoalScorer?) {} func mainServiceDidDetectGameEnd(_: Scoreboard.Game) {} } class MainService: @unchecked Sendable { private let logger = IceGlassLogger( subsystem: Bundle.main.bundleIdentifier ?? "dev.rzen.indie.IceGlass", category: "MainService" ) static let shared = MainService() private lazy var settings = AppSettings.shared private let cache = NHLDataCache() /// Set by each platform's app entry to receive update callbacks. /// Always invoked on the main actor by the methods below; the property /// itself is unisolated so MainService.shared can be referenced from /// nonisolated contexts (e.g. AppDelegate's stored property init). nonisolated(unsafe) weak var observer: (any MainServiceObserver)? private var pollingTimer: Timer? private var scoreboardApi: ApiService? private var standingsApi: ApiService? private var bracketApi: ApiService? private var bracketApiSeasonYear: Int? /// All game days from the API (full window: yesterday/today/tomorrow) private var allGamesByDate: [Scoreboard.GameDay] = [] /// Current standings data var standings: Standings? /// Current playoff bracket (nil during regular season or before first fetch) var bracket: PlayoffBracket? /// Timestamp of the most recent successful fetch (any endpoint). /// Surface this in the iOS UI as the "as of" indicator. private(set) var lastUpdated: Date? /// Previous game snapshots for detecting state/score changes (keyed by game ID) private var previousGameStates: [Int: GameSnapshot] = [:] /// Whether this is the first fetch (suppress notifications on initial load) private var isFirstFetch = true /// Set during change detection when a playoff game transitions to a final state, /// so the next scoreboard cycle can force a bracket refresh. private var hadPlayoffFinalTransition = false struct GameSnapshot { let gameState: String let awayScore: Int? let homeScore: Int? } /// Game days filtered by display option var gamesByDate: [Scoreboard.GameDay] { let includedDates = settings.displayOption.includedDates() return allGamesByDate.filter { includedDates.contains($0.date) } } /// Status bar text based on current settings var statusBarText: String { switch settings.statusBarOption { case .gameCount: let gameDays = gamesByDate if gameDays.isEmpty { return "" } return gameDays.map { "\($0.games.count)" }.joined(separator: "/") case .gamesPlayed: return "\(standings?.totalGamesPlayed ?? 0)" case .gamesPlayedTotal: return "\(standings?.totalGamesPlayed ?? 0)/\(Standings.totalRegularSeasonGames)" } } /// Whether any game across all days is currently live var anyGameLive: Bool { allGamesByDate.flatMap(\.games).contains { $0.parsedGameState.isLive } } /// Whether any game is in pre-game state var anyGamePre: Bool { allGamesByDate.flatMap(\.games).contains { $0.parsedGameState == .pre } } /// Whether any game today is scheduled (future) var anyGameToday: Bool { let today = Date.todayET return allGamesByDate.contains { $0.date == today && !$0.games.isEmpty } } /// Series in the current playoff round paired with each one's next scheduled /// game from the fetched window (if any). Empty during regular season. var currentRoundSeriesItems: [RoundSeriesItem] { guard let bracket = bracket else { return [] } let windowGames = allGamesByDate.flatMap(\.games) return bracket.currentRoundSeries.map { series in let nextGame = windowGames .filter { !$0.parsedGameState.isOver && series.involves(away: $0.awayTeam.abbrev, home: $0.homeTeam.abbrev) } .min { $0.date < $1.date } return RoundSeriesItem(series: series, nextGame: nextGame) } } struct RoundSeriesItem { let series: PlayoffBracket.Series let nextGame: Scoreboard.Game? } /// The best polling interval based on current game states var bestPollingInterval: PollingInterval { if anyGameLive { return .liveGame } if anyGamePre { return .preGame } if anyGameToday { return .gameDay } return .idle } private init() { logger.debug("Initializing") DispatchQueue.main.async { [weak self] in guard let self = self else { return } Task { @MainActor in await self.loadFromCache() self.initApis() self.reschedulePollingTimer(.bootstrap) await self.fetchScoreboard() await self.fetchStandings() } } } // MARK: - Cache /// Loads the persisted snapshot (if any) so the UI can paint last-known /// data immediately. The fresh fetch in `init` then overwrites in-memory /// state and the cache file. private func loadFromCache() async { guard let snapshot = await cache.load() else { return } if let scoreboard = snapshot.scoreboard { let yesterday = Date.yesterdayET let today = Date.todayET let tomorrow = Date.tomorrowET let windowDates = Set([yesterday, today, tomorrow]) let filtered = scoreboard.gamesByDate.filter { windowDates.contains($0.date) } self.allGamesByDate = filtered self.updateSnapshots(from: filtered) } self.standings = snapshot.standings self.bracket = snapshot.bracket self.lastUpdated = snapshot.lastUpdated logger.info("Loaded cached snapshot from \(snapshot.lastUpdated)") Task { @MainActor in self.observer?.mainServiceDidUpdate() } } private func persistCache() { // Reconstruct a synthetic Scoreboard from the windowed allGamesByDate // so the cached payload is exactly what we'd render on next launch. let scoreboard = Scoreboard( focusedDate: Date.todayET, focusedDateCount: allGamesByDate.first { $0.date == Date.todayET }?.games.count ?? 0, gamesByDate: allGamesByDate ) let snapshot = CachedSnapshot( lastUpdated: Date(), scoreboard: scoreboard, standings: standings, bracket: bracket ) Task { [cache] in await cache.save(snapshot) } } private func initApis() { scoreboardApi = ApiService( url: URL(string: "https://api-web.nhle.com/v1/scoreboard/now") ) { [weak self] data, _ in guard let self = self else { return } do { let scoreboard = try JSONDecoder().decode(Scoreboard.self, from: data) let yesterday = Date.yesterdayET let today = Date.todayET let tomorrow = Date.tomorrowET let windowDates = Set([yesterday, today, tomorrow]) let filtered = scoreboard.gamesByDate.filter { windowDates.contains($0.date) } if !self.isFirstFetch { self.detectChanges(in: filtered) } self.allGamesByDate = filtered self.updateSnapshots(from: filtered) self.lastUpdated = Date() if self.isFirstFetch { self.isFirstFetch = false } self.logger.info("Scoreboard updated: \(filtered.map { "\($0.date): \($0.games.count) games" }.joined(separator: ", "))") let interval = self.bestPollingInterval let shouldForceBracket = self.hadPlayoffFinalTransition self.hadPlayoffFinalTransition = false Task { @MainActor in self.reschedulePollingTimer(interval) await self.refreshBracketIfNeeded(from: filtered, force: shouldForceBracket) } self.persistCache() self.notifyObserverDidUpdate() } catch { self.logger.error("Failed to decode scoreboard: \(error.localizedDescription)") } } standingsApi = ApiService( url: URL(string: "https://api-web.nhle.com/v1/standings/\(Date.todayET)") ) { [weak self] data, _ in guard let self = self else { return } do { self.standings = try JSONDecoder().decode(Standings.self, from: data) self.lastUpdated = Date() self.logger.info("Standings updated: \(self.standings?.standings.count ?? 0) teams") self.persistCache() self.notifyObserverDidUpdate() } catch { self.logger.error("Failed to decode standings: \(error.localizedDescription)") } } } private func notifyObserverDidUpdate() { Task { @MainActor in self.observer?.mainServiceDidUpdate() } } /// Fires `mainServiceDidUpdate` without triggering a network fetch — call /// after settings changes that affect what the existing data renders as. func updateUI() { notifyObserverDidUpdate() } // MARK: - Change Detection private func detectChanges(in gameDays: [Scoreboard.GameDay]) { for gameDay in gameDays { for game in gameDay.games { guard let previous = previousGameStates[game.id] else { continue } let previousState = GameState(rawValue: previous.gameState) let currentState = game.parsedGameState if let prevState = previousState, prevState.isFuture, currentState.isLive { logger.info("Game \(game.id) started: \(game.awayTeam.abbrev) @ \(game.homeTeam.abbrev)") let captured = game Task { @MainActor in self.observer?.mainServiceDidDetectGameStart(captured) } } // Game ended: transition to any over-state (OVER/FINAL/OFF) if let prevState = previousState, !prevState.isOver, currentState.isOver { logger.info("Game \(game.id) ended: \(game.awayTeam.abbrev) \(game.awayTeam.score ?? 0) — \(game.homeTeam.abbrev) \(game.homeTeam.score ?? 0)") let captured = game Task { @MainActor in self.observer?.mainServiceDidDetectGameEnd(captured) } } if game.gameType == 3, let prevState = previousState, !prevState.isOver, currentState.isOver { hadPlayoffFinalTransition = true } if let prevAway = previous.awayScore, let prevHome = previous.homeScore, let curAway = game.awayTeam.score, let curHome = game.homeTeam.score { if curAway > prevAway { logger.info("Goal! \(game.awayTeam.abbrev) scored in game \(game.id): \(curAway):\(curHome)") handleGoal(game: game, scoringTeam: game.awayTeam, awayScore: curAway, homeScore: curHome) } if curHome > prevHome { logger.info("Goal! \(game.homeTeam.abbrev) scored in game \(game.id): \(curAway):\(curHome)") handleGoal(game: game, scoringTeam: game.homeTeam, awayScore: curAway, homeScore: curHome) } } } } } private func handleGoal(game: Scoreboard.Game, scoringTeam: Scoreboard.Game.Team, awayScore: Int, homeScore: Int) { Task { [weak self] in guard let self = self else { return } let scorer = await self.fetchGoalScorer( gameId: game.id, awayScore: awayScore, homeScore: homeScore ) await MainActor.run { self.observer?.mainServiceDidDetectGoal(game, scoringTeam: scoringTeam, scorer: scorer) } } } private func fetchGoalScorer(gameId: Int, awayScore: Int, homeScore: Int) async -> GoalScorer? { guard let url = URL(string: "https://api-web.nhle.com/v1/gamecenter/\(gameId)/play-by-play") else { return nil } do { let (data, _) = try await URLSession.shared.data(from: url) let pbp = try JSONDecoder().decode(PlayByPlay.self, from: data) guard let goal = pbp.goal(matchingAwayScore: awayScore, homeScore: homeScore), let playerId = goal.details?.scoringPlayerId, let player = pbp.player(id: playerId) else { logger.info("Play-by-play not yet caught up for goal at \(awayScore)-\(homeScore) in game \(gameId)") return nil } let scoringTeamIsAway = goal.details?.eventOwnerTeamId == pbp.awayTeam.id let strength = PlayByPlay.strengthTag( situationCode: goal.situationCode, scoringTeamIsAway: scoringTeamIsAway ) let firstInitial = player.firstName.default.first.map { "\($0)." } ?? "" let name = "\(firstInitial) \(player.lastName.default)".trimmingCharacters(in: .whitespaces) return GoalScorer(name: name, sweaterNumber: player.sweaterNumber, strength: strength) } catch { logger.error("Failed to fetch play-by-play for game \(gameId): \(error.localizedDescription)") return nil } } private func updateSnapshots(from gameDays: [Scoreboard.GameDay]) { for gameDay in gameDays { for game in gameDay.games { previousGameStates[game.id] = GameSnapshot( gameState: game.gameState, awayScore: game.awayTeam.score, homeScore: game.homeTeam.score ) } } } // MARK: - Polling private func reschedulePollingTimer(_ pollingInterval: PollingInterval) { if !Thread.isMainThread { logger.warning("reschedulePollingTimer called not on main thread") return } let rescheduleTolerance: TimeInterval = 3 var needsRescheduling = false if self.pollingTimer == nil { needsRescheduling = true } else if !self.pollingTimer!.isValid { needsRescheduling = true } else if abs(self.pollingTimer!.fireDate.timeIntervalSinceNow - pollingInterval.rawValue) > rescheduleTolerance { needsRescheduling = true } if needsRescheduling { self.logger.debug("Rescheduling polling timer to \(pollingInterval.rawValue)s") self.pollingTimer = Timer.startTimer( timer: self.pollingTimer, interval: pollingInterval.rawValue ) { [weak self] _ in Task { @MainActor in await self?.fetchScoreboard() } } } } /// Cancel the polling timer (call from iOS when entering background). @MainActor func suspendPolling() { pollingTimer?.invalidate() pollingTimer = nil logger.debug("Polling suspended") } /// Resume polling at the appropriate interval (call from iOS on scenePhase=.active). @MainActor func resumePolling() { reschedulePollingTimer(bestPollingInterval) } private func fetchScoreboard() async { await scoreboardApi?.fetch() } private func fetchStandings() async { await standingsApi?.fetch() } func fetchAll() async { await fetchScoreboard() await fetchStandings() } // MARK: - Playoff Bracket private func refreshBracketIfNeeded(from gameDays: [Scoreboard.GameDay], force: Bool) async { let playoffGame = gameDays.flatMap(\.games).first { $0.gameType == 3 } guard let playoffGame = playoffGame else { bracket = nil return } let seasonYear = playoffGame.season / 10_000 let seasonChanged = bracketApi == nil || bracketApiSeasonYear != seasonYear if seasonChanged { bracketApiSeasonYear = seasonYear bracketApi = ApiService( url: URL(string: "https://api-web.nhle.com/v1/playoff-bracket/\(seasonYear + 1)") ) { [weak self] data, _ in guard let self = self else { return } do { self.bracket = try JSONDecoder().decode(PlayoffBracket.self, from: data) self.lastUpdated = Date() self.logger.info("Bracket updated: \(self.bracket?.series.count ?? 0) series (round \(self.bracket?.currentRound ?? 0))") self.persistCache() self.notifyObserverDidUpdate() } catch { self.logger.error("Failed to decode bracket: \(error.localizedDescription)") } } } if bracket == nil || seasonChanged || force { await bracketApi?.fetch() } } }