Initial commit: IceGlass NHL game tracker
macOS menu bar app providing NHL game situational awareness with league-wide scoreboard, dynamic polling, notifications with team logos, and configurable display options.
This commit is contained in:
@@ -0,0 +1,226 @@
|
||||
//
|
||||
// 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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user