// // MainService.swift // IceGlass // // Copyright 2026 Rouslan Zenetl. All Rights Reserved. // import Foundation 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 menuManager = MenuManager.shared private lazy var statusItemManager = StatusItemManager.shared private lazy var settings = AppSettings.shared private lazy var notificationManager = NotificationManager.shared private var pollingTimer: Timer? private var scoreboardApi: ApiService? /// All game days from the API (full window: yesterday/today/tomorrow) private var allGamesByDate: [Scoreboard.GameDay] = [] /// 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 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) } } /// Whether any game across all days is currently live var anyGameLive: Bool { allGamesByDate.flatMap(\.games).contains { game in game.parsedGameState.isLive } } /// Whether any game is in pre-game state var anyGamePre: Bool { allGamesByDate.flatMap(\.games).contains { game in game.parsedGameState == .pre } } /// Whether any game today is scheduled (future) var anyGameToday: Bool { let today = Date.todayET return allGamesByDate.contains { gd in gd.date == today && !gd.games.isEmpty } } /// 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 { AppTerminator.terminate() return } Task { @MainActor in self.initApis() self.reschedulePollingTimer(.bootstrap) await self.fetchScoreboard() } } } 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) // Filter to yesterday/today/tomorrow window let yesterday = Date.yesterdayET let today = Date.todayET let tomorrow = Date.tomorrowET let windowDates = Set([yesterday, today, tomorrow]) let filtered = scoreboard.gamesByDate.filter { gd in windowDates.contains(gd.date) } // Detect state and score changes before updating if !self.isFirstFetch { self.detectChanges(in: filtered) } self.allGamesByDate = filtered // Update snapshots for next comparison self.updateSnapshots(from: filtered) if self.isFirstFetch { self.isFirstFetch = false } self.logger.info("Scoreboard updated: \(filtered.map { "\($0.date): \($0.games.count) games" }.joined(separator: ", "))") // Adjust polling based on game states let interval = self.bestPollingInterval Task { @MainActor in self.reschedulePollingTimer(interval) } self.statusItemManager.updateGameCounts(self.gamesByDate) self.menuManager.scoreboardChanged() } catch { self.logger.error("Failed to decode scoreboard: \(error.localizedDescription)") } } } // 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 // Game started: FUT/PRE → LIVE if let prevState = previousState, prevState.isFuture, currentState.isLive { logger.info("Game \(game.id) started: \(game.awayTeam.abbrev) @ \(game.homeTeam.abbrev)") notificationManager.notifyGameStarted(game) } // Goal scored: score increased on either team 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)") notificationManager.notifyGoalScored(game, scoringTeam: game.awayTeam) } if curHome > prevHome { logger.info("Goal! \(game.homeTeam.abbrev) scored in game \(game.id): \(curAway):\(curHome)") notificationManager.notifyGoalScored(game, scoringTeam: game.homeTeam) } } } } } 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() } } } } private func fetchScoreboard() async { await scoreboardApi?.fetch() } func fetchAll() async { await fetchScoreboard() } }