18c4ef64d6
Fires the first time a game transitions from non-over to any over-state
(OVER/FINAL/OFF), so the notification lands when the clock runs out
rather than hours later when NHL statisticians stamp OFF. Shows the
winning team's logo with a "{TEAM} wins" title and final score body.
Deduped by game ID via gameEndsSent; cleared on resetForNewDay.
Dev menu: "Test Game Ended Notification" for manual trigger.
376 lines
14 KiB
Swift
376 lines
14 KiB
Swift
//
|
|
// 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?
|
|
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?
|
|
|
|
/// Previous game snapshots for detecting state/score changes (keyed by game ID)
|
|
private var previousGameStates: [Int: GameSnapshot] = [:]
|
|
|
|
/// Whether this is the first fe tch (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 {
|
|
AppTerminator.terminate()
|
|
return
|
|
}
|
|
|
|
Task { @MainActor in
|
|
self.initApis()
|
|
self.reschedulePollingTimer(.bootstrap)
|
|
await self.fetchScoreboard()
|
|
await self.fetchStandings()
|
|
}
|
|
}
|
|
}
|
|
|
|
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)
|
|
|
|
let wasFirstFetch = self.isFirstFetch
|
|
if self.isFirstFetch {
|
|
self.isFirstFetch = false
|
|
}
|
|
|
|
#if DEBUG
|
|
// Fire a test game-start notification on startup so the dev
|
|
// loop doesn't require clicking through the menu each time.
|
|
if wasFirstFetch, let game = filtered.flatMap(\.games).first {
|
|
self.notificationManager.notifyGameStarted(game, bypassDedup: true)
|
|
}
|
|
#endif
|
|
|
|
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.updateUI()
|
|
} 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.logger.info("Standings updated: \(self.standings?.standings.count ?? 0) teams")
|
|
self.updateUI()
|
|
} catch {
|
|
self.logger.error("Failed to decode standings: \(error.localizedDescription)")
|
|
}
|
|
}
|
|
}
|
|
|
|
func updateUI() {
|
|
statusItemManager.updateStatusText(statusBarText)
|
|
menuManager.scoreboardChanged()
|
|
}
|
|
|
|
// 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)")
|
|
notificationManager.notifyGameStarted(game)
|
|
}
|
|
|
|
// 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)")
|
|
notificationManager.notifyGameEnded(game)
|
|
}
|
|
|
|
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
|
|
)
|
|
self.notificationManager.notifyGoalScored(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()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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.logger.info("Bracket updated: \(self.bracket?.series.count ?? 0) series (round \(self.bracket?.currentRound ?? 0))")
|
|
self.updateUI()
|
|
} catch {
|
|
self.logger.error("Failed to decode bracket: \(error.localizedDescription)")
|
|
}
|
|
}
|
|
}
|
|
|
|
if bracket == nil || seasonChanged || force {
|
|
await bracketApi?.fetch()
|
|
}
|
|
}
|
|
}
|