Add playoff round view, game numbers, goal scorer notifications, standings

- Fetch NHL standings and surface league/season game counts in the menu bar
- Prefix regular-season rows with the league-wide game number (from gameId)
- New ROUND section shows each active playoff series (matchup, series score,
  next game number + time) derived from /v1/playoff-bracket; rows always open
  the NHL series page so completed series remain clickable
- Goal notifications include scorer sweater, abbreviated name, and strength
  (PPG/SHG/EN), resolved via /v1/gamecenter/{id}/play-by-play
- Drop the per-team filter submenu and NHLTeam enum
- Regenerate AppIcon with the full 10-size macOS set (alpha preserved) so
  notifications render the app icon correctly; rename the iOS marketing PNG
  to icon-ios-1024.png
- gitignore .claude/ local tooling settings
This commit is contained in:
2026-04-18 21:51:27 -04:00
parent 8f8f8b2755
commit 57358797e1
44 changed files with 596 additions and 286 deletions
+158 -24
View File
@@ -22,16 +22,29 @@ class MainService: @unchecked Sendable {
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 fetch (suppress notifications on initial load)
/// 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?
@@ -44,28 +57,56 @@ class MainService: @unchecked Sendable {
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 { game in
game.parsedGameState.isLive
}
allGamesByDate.flatMap(\.games).contains { $0.parsedGameState.isLive }
}
/// Whether any game is in pre-game state
var anyGamePre: Bool {
allGamesByDate.flatMap(\.games).contains { game in
game.parsedGameState == .pre
}
allGamesByDate.flatMap(\.games).contains { $0.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
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 }
@@ -86,6 +127,7 @@ class MainService: @unchecked Sendable {
self.initApis()
self.reschedulePollingTimer(.bootstrap)
await self.fetchScoreboard()
await self.fetchStandings()
}
}
}
@@ -99,24 +141,18 @@ class MainService: @unchecked Sendable {
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)
}
let filtered = scoreboard.gamesByDate.filter { windowDates.contains($0.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 {
@@ -125,18 +161,38 @@ class MainService: @unchecked Sendable {
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
let shouldForceBracket = self.hadPlayoffFinalTransition
self.hadPlayoffFinalTransition = false
Task { @MainActor in
self.reschedulePollingTimer(interval)
await self.refreshBracketIfNeeded(from: filtered, force: shouldForceBracket)
}
self.statusItemManager.updateGameCounts(self.gamesByDate)
self.menuManager.scoreboardChanged()
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
@@ -149,29 +205,70 @@ class MainService: @unchecked Sendable {
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 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)")
notificationManager.notifyGoalScored(game, scoringTeam: game.awayTeam)
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)")
notificationManager.notifyGoalScored(game, scoringTeam: game.homeTeam)
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 {
@@ -220,7 +317,44 @@ class MainService: @unchecked Sendable {
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()
}
}
}