Files
iceglass/Shared/Services/MainService.swift
T
rzen 541aa3d52c Show full playoff bracket, mark series results, harden API decoding
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 "-:-"
2026-05-30 08:21:28 -04:00

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 (1current). 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 FUTPRELIVECRITOVERFINALOFF 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
}
}