541aa3d52c
Playoffs: - List every round played so far (Round 1 → current) instead of only the current round, on both macOS menu and iPhone - Strike through the eliminated team's tricode in a finished series and drop the now-redundant "(Final … wins)" tag on completed earlier rounds - Refetch the bracket when a finished game implies more completed games than the cached bracket records, so the series score and round no longer get stuck on stale data after cold launch or the NHL bracket endpoint's lag API robustness: - Tolerate optional gameCenterLink/startTimeUTC on TBD playoff matchups so the scoreboard decode no longer aborts - Reject API state regressions via a monotonic FUT→…→OFF progression rank so a brief glitch can't downgrade a finished game back to "-:-"
531 lines
21 KiB
Swift
531 lines
21 KiB
Swift
//
|
|
// 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 }
|
|
}
|
|
|
|
/// Every active playoff round paired with its matched series, ascending by
|
|
/// round (1…current). Each series carries its next scheduled game from the
|
|
/// fetched window (if any). Empty during the regular season.
|
|
var roundSeriesGroups: [RoundGroup] {
|
|
guard let bracket = bracket else { return [] }
|
|
return bracket.activeRounds.map { round in
|
|
RoundGroup(round: round, items: roundSeriesItems(for: bracket.matchedSeries(inRound: round)))
|
|
}
|
|
}
|
|
|
|
/// Pairs each series with its next unplayed game from the fetched window.
|
|
private func roundSeriesItems(for seriesList: [PlayoffBracket.Series]) -> [RoundSeriesItem] {
|
|
let windowGames = allGamesByDate.flatMap(\.games)
|
|
return seriesList.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 RoundGroup {
|
|
let round: Int
|
|
let items: [RoundSeriesItem]
|
|
}
|
|
|
|
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 = mergeWithMonotonicState(filtered)
|
|
self.updateSnapshots(from: self.allGamesByDate)
|
|
}
|
|
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) }
|
|
let merged = self.mergeWithMonotonicState(filtered)
|
|
|
|
if !self.isFirstFetch {
|
|
self.detectChanges(in: merged)
|
|
}
|
|
|
|
self.allGamesByDate = merged
|
|
self.updateSnapshots(from: merged)
|
|
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
|
|
}
|
|
}
|
|
|
|
/// Reject API responses that downgrade a known game to an earlier state in
|
|
/// the FUT→PRE→LIVE→CRIT→OVER→FINAL→OFF progression. The NHL `/scoreboard/now`
|
|
/// CDN occasionally serves yesterday's finished games as `FUT` during the
|
|
/// daily focusedDate rollover; without this guard, iOS opens cold in the
|
|
/// morning, sees that one bad response, and shows `-:-` until the API
|
|
/// self-corrects. macOS polls through it but iOS only gets one chance.
|
|
private func mergeWithMonotonicState(_ incoming: [Scoreboard.GameDay]) -> [Scoreboard.GameDay] {
|
|
let previousById = Dictionary(
|
|
uniqueKeysWithValues: allGamesByDate.flatMap(\.games).map { ($0.id, $0) }
|
|
)
|
|
return incoming.map { day in
|
|
let games = day.games.map { newGame -> Scoreboard.Game in
|
|
guard let prev = previousById[newGame.id] else { return newGame }
|
|
let prevRank = GameState.progressionRank(of: prev.gameState)
|
|
let newRank = GameState.progressionRank(of: newGame.gameState)
|
|
guard prevRank > newRank else { return newGame }
|
|
logger.warning("Rejecting state regression for game \(newGame.id) (\(newGame.awayTeam.abbrev)@\(newGame.homeTeam.abbrev)): API \(newGame.gameState) < held \(prev.gameState)")
|
|
return prev
|
|
}
|
|
return Scoreboard.GameDay(date: day.date, games: games)
|
|
}
|
|
}
|
|
|
|
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 playoffGames = gameDays.flatMap(\.games).filter { $0.gameType == 3 }
|
|
guard let playoffGame = playoffGames.first 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 || isBracketStale(against: playoffGames) {
|
|
await bracketApi?.fetch()
|
|
}
|
|
}
|
|
|
|
/// True when a finished playoff game in the window implies more completed
|
|
/// games than the cached bracket has recorded for that series — i.e. the
|
|
/// bracket is behind the scoreboard. Catches both a stale cached bracket
|
|
/// loaded on cold launch and the lag between a game going final and the NHL
|
|
/// bracket endpoint updating its win counts. Self-clears once the bracket
|
|
/// catches up, so it stops triggering fetches as soon as the two agree.
|
|
private func isBracketStale(against playoffGames: [Scoreboard.Game]) -> Bool {
|
|
guard let bracket = bracket else { return false }
|
|
for game in playoffGames where game.parsedGameState.isOver {
|
|
guard let context = game.playoffContext,
|
|
let series = bracket.series(for: game) else { continue }
|
|
if series.topSeedWins + series.bottomSeedWins < context.gameInSeries {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
}
|