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:
2026-04-13 21:44:08 -04:00
commit 8f8f8b2755
158 changed files with 2752 additions and 0 deletions
+45
View File
@@ -0,0 +1,45 @@
//
// ApiService.swift
// IceGlass
//
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
//
import Foundation
typealias ApiServiceCallback = (_ jsonData: Data, _ response: URLResponse) -> Void
class ApiService: @unchecked Sendable {
private let logger = IceGlassLogger(
subsystem: Bundle.main.bundleIdentifier ?? "dev.rzen.indie.IceGlass",
category: "ApiService"
)
let url: URL
let callback: ApiServiceCallback
var previousFetchTime: Date = .distantPast
init(url: URL?, callback: @escaping ApiServiceCallback) {
guard let url = url else {
AppTerminator.terminate()
self.url = URL(string: "")!
self.callback = { (_: Data, _: URLResponse) in }
return
}
self.url = url
self.callback = callback
}
func fetch() async {
do {
let (data, response) = try await URLSession.shared.data(from: url)
logger.info("Polling: \(self.url)")
logger.debug("Previous: \(Date.now.timeIntervalSince(previousFetchTime).humanReadableTime())")
previousFetchTime = Date.now
callback(data, response)
} catch {
logger.error("Invalid response from \(self.url) \(error)")
}
}
}
+226
View File
@@ -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()
}
}
+17
View File
@@ -0,0 +1,17 @@
//
// PollingInterval.swift
// IceGlass
//
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
//
import Foundation
enum PollingInterval: TimeInterval {
case idle = 3600
case bootstrap = 42
case liveGame = 7
case gameDay = 600
case preGame = 180
case everyMinute = 60
}