diff --git a/.gitignore b/.gitignore
index 50b2ca7..6022897 100644
--- a/.gitignore
+++ b/.gitignore
@@ -17,3 +17,6 @@ xcuserdata/
# Project-specific
IceGlass.xcodeproj/
+
+# Claude Code local settings
+.claude/
diff --git a/Artwork/IceGlass.pxd b/Artwork/IceGlass.pxd
new file mode 100644
index 0000000..700c2a4
Binary files /dev/null and b/Artwork/IceGlass.pxd differ
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b7b8769..ab393cf 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,11 @@
## April 2026
+- Goal notifications now include the scorer's sweater number, name, and strength tag (PPG / SHG / EN) when available (derived from `/gamecenter/{id}/play-by-play`)
+- Regular-season game rows now show the league-wide game number (derived from the NHL gameId)
+- New PLAYOFFS ROUND section lists every active series with series score, next game number, and upcoming tip-off time
+- Playoff bracket fetched from `api-web.nhle.com/v1/playoff-bracket/{year}` only when needed (initial load and after a playoff game finalizes)
+- Removed Team filter option (per-team filtering and team-specific menu bar icon)
- Initial project setup with macOS menu bar app
- NHL scoreboard API integration showing yesterday/today/tomorrow games
- Dynamic polling based on game state (7s live, 10min game day, 1hr idle)
diff --git a/CLAUDE.md b/CLAUDE.md
index 2945859..eae3af8 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -135,7 +135,7 @@ NHL operates on Eastern Time. Date extensions handle timezone conversions:
IceGlass/
├── Services/ - Core business logic (MainService, ApiService, PollingInterval)
├── Managers/ - UI coordination (StatusItemManager, MenuManager, NotificationManager, AppSettings)
-├── Models/ - Codable data models (ScoreboardModel, GameState, NHLTeam)
+├── Models/ - Codable data models (ScoreboardModel, StandingsModel, GameState)
├── Extensions/ - Date/Timer/NSMenuItem/TimeInterval helpers
├── Lib/ - Utilities (IceGlassLogger, AppTerminator)
├── About/ - About window (AboutWindow with IndieAbout)
diff --git a/IceGlass/AppDelegate.swift b/IceGlass/AppDelegate.swift
index ee2caa0..a55fffc 100644
--- a/IceGlass/AppDelegate.swift
+++ b/IceGlass/AppDelegate.swift
@@ -7,6 +7,7 @@
import UserNotifications
import AppKit
+import CoreServices
class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDelegate {
private let logger = IceGlassLogger(
@@ -20,8 +21,12 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele
logger.info("applicationDidFinishLaunching")
UNUserNotificationCenter.current().delegate = self
- // Set app icon explicitly (LSUIElement apps may not pick it up automatically)
- if let icon = NSImage(named: "AppIcon") {
+ // Force re-register with Launch Services to refresh cached icon
+ LSRegisterURL(Bundle.main.bundleURL as CFURL, true)
+
+ // Set app icon explicitly from .icns (NSImage(named:) doesn't work for App Icon assets)
+ if let icnsPath = Bundle.main.path(forResource: "AppIcon", ofType: "icns"),
+ let icon = NSImage(contentsOfFile: icnsPath) {
NSApp.applicationIconImage = icon
}
}
diff --git a/IceGlass/Assets.xcassets/AppIcon.appiconset/Contents.json b/IceGlass/Assets.xcassets/AppIcon.appiconset/Contents.json
index f70dcf3..b092a13 100644
--- a/IceGlass/Assets.xcassets/AppIcon.appiconset/Contents.json
+++ b/IceGlass/Assets.xcassets/AppIcon.appiconset/Contents.json
@@ -1,112 +1,10 @@
{
"images": [
{
- "filename": "icon-20@2x.png",
- "idiom": "iphone",
- "size": "20x20",
- "scale": "2x"
- },
- {
- "filename": "icon-20@3x.png",
- "idiom": "iphone",
- "size": "20x20",
- "scale": "3x"
- },
- {
- "filename": "icon-29@2x.png",
- "idiom": "iphone",
- "size": "29x29",
- "scale": "2x"
- },
- {
- "filename": "icon-29@3x.png",
- "idiom": "iphone",
- "size": "29x29",
- "scale": "3x"
- },
- {
- "filename": "icon-40@2x.png",
- "idiom": "iphone",
- "size": "40x40",
- "scale": "2x"
- },
- {
- "filename": "icon-40@3x.png",
- "idiom": "iphone",
- "size": "40x40",
- "scale": "3x"
- },
- {
- "filename": "icon-60@2x.png",
- "idiom": "iphone",
- "size": "60x60",
- "scale": "2x"
- },
- {
- "filename": "icon-60@3x.png",
- "idiom": "iphone",
- "size": "60x60",
- "scale": "3x"
- },
- {
- "filename": "icon-20.png",
- "idiom": "ipad",
- "size": "20x20",
- "scale": "1x"
- },
- {
- "filename": "icon-20@2x.png",
- "idiom": "ipad",
- "size": "20x20",
- "scale": "2x"
- },
- {
- "filename": "icon-29.png",
- "idiom": "ipad",
- "size": "29x29",
- "scale": "1x"
- },
- {
- "filename": "icon-29@2x.png",
- "idiom": "ipad",
- "size": "29x29",
- "scale": "2x"
- },
- {
- "filename": "icon-40.png",
- "idiom": "ipad",
- "size": "40x40",
- "scale": "1x"
- },
- {
- "filename": "icon-40@2x.png",
- "idiom": "ipad",
- "size": "40x40",
- "scale": "2x"
- },
- {
- "filename": "icon-76.png",
- "idiom": "ipad",
- "size": "76x76",
- "scale": "1x"
- },
- {
- "filename": "icon-76@2x.png",
- "idiom": "ipad",
- "size": "76x76",
- "scale": "2x"
- },
- {
- "filename": "icon-83.5@2x.png",
- "idiom": "ipad",
- "size": "83.5x83.5",
- "scale": "2x"
- },
- {
- "filename": "icon-1024.png",
- "idiom": "ios-marketing",
- "size": "1024x1024",
- "scale": "1x"
+ "filename": "icon-ios-1024.png",
+ "idiom": "universal",
+ "platform": "ios",
+ "size": "1024x1024"
},
{
"filename": "icon-mac-16.png",
@@ -173,4 +71,4 @@
"author": "xcode",
"version": 1
}
-}
+}
\ No newline at end of file
diff --git a/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-20.png b/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-20.png
deleted file mode 100644
index be997d0..0000000
Binary files a/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-20.png and /dev/null differ
diff --git a/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-20@2x.png b/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-20@2x.png
deleted file mode 100644
index d9b9dc8..0000000
Binary files a/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-20@2x.png and /dev/null differ
diff --git a/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-20@3x.png b/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-20@3x.png
deleted file mode 100644
index 5a3140d..0000000
Binary files a/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-20@3x.png and /dev/null differ
diff --git a/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-29.png b/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-29.png
deleted file mode 100644
index acdbeff..0000000
Binary files a/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-29.png and /dev/null differ
diff --git a/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-29@2x.png b/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-29@2x.png
deleted file mode 100644
index 3312dfc..0000000
Binary files a/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-29@2x.png and /dev/null differ
diff --git a/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-29@3x.png b/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-29@3x.png
deleted file mode 100644
index 13b7c07..0000000
Binary files a/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-29@3x.png and /dev/null differ
diff --git a/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-40.png b/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-40.png
deleted file mode 100644
index d9b9dc8..0000000
Binary files a/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-40.png and /dev/null differ
diff --git a/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-40@2x.png b/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-40@2x.png
deleted file mode 100644
index 4374690..0000000
Binary files a/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-40@2x.png and /dev/null differ
diff --git a/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-40@3x.png b/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-40@3x.png
deleted file mode 100644
index a670d6a..0000000
Binary files a/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-40@3x.png and /dev/null differ
diff --git a/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-60@2x.png b/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-60@2x.png
deleted file mode 100644
index a670d6a..0000000
Binary files a/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-60@2x.png and /dev/null differ
diff --git a/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-60@3x.png b/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-60@3x.png
deleted file mode 100644
index 9c5e714..0000000
Binary files a/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-60@3x.png and /dev/null differ
diff --git a/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-76.png b/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-76.png
deleted file mode 100644
index 7685cff..0000000
Binary files a/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-76.png and /dev/null differ
diff --git a/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-76@2x.png b/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-76@2x.png
deleted file mode 100644
index 52cd991..0000000
Binary files a/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-76@2x.png and /dev/null differ
diff --git a/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-83.5@2x.png b/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-83.5@2x.png
deleted file mode 100644
index c44eab3..0000000
Binary files a/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-83.5@2x.png and /dev/null differ
diff --git a/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-1024.png b/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-ios-1024.png
similarity index 100%
rename from IceGlass/Assets.xcassets/AppIcon.appiconset/icon-1024.png
rename to IceGlass/Assets.xcassets/AppIcon.appiconset/icon-ios-1024.png
diff --git a/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-mac-128.png b/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-mac-128.png
index a0fb059..2ea5958 100644
Binary files a/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-mac-128.png and b/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-mac-128.png differ
diff --git a/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-mac-128@2x.png b/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-mac-128@2x.png
index ea2b48d..175eda0 100644
Binary files a/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-mac-128@2x.png and b/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-mac-128@2x.png differ
diff --git a/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-mac-16.png b/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-mac-16.png
index e27490d..e52ad43 100644
Binary files a/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-mac-16.png and b/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-mac-16.png differ
diff --git a/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-mac-16@2x.png b/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-mac-16@2x.png
index 9803fc1..5bd2c2d 100644
Binary files a/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-mac-16@2x.png and b/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-mac-16@2x.png differ
diff --git a/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-mac-256.png b/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-mac-256.png
index ea2b48d..175eda0 100644
Binary files a/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-mac-256.png and b/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-mac-256.png differ
diff --git a/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-mac-256@2x.png b/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-mac-256@2x.png
index a33506d..13472e4 100644
Binary files a/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-mac-256@2x.png and b/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-mac-256@2x.png differ
diff --git a/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-mac-32.png b/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-mac-32.png
index 9803fc1..5bd2c2d 100644
Binary files a/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-mac-32.png and b/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-mac-32.png differ
diff --git a/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-mac-32@2x.png b/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-mac-32@2x.png
index 94922d1..60d8fde 100644
Binary files a/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-mac-32@2x.png and b/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-mac-32@2x.png differ
diff --git a/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-mac-512.png b/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-mac-512.png
index a33506d..13472e4 100644
Binary files a/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-mac-512.png and b/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-mac-512.png differ
diff --git a/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-mac-512@2x.png b/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-mac-512@2x.png
index 3f6fa3d..130dd90 100644
Binary files a/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-mac-512@2x.png and b/IceGlass/Assets.xcassets/AppIcon.appiconset/icon-mac-512@2x.png differ
diff --git a/IceGlass/Info.plist b/IceGlass/Info.plist
index 0062e07..713901d 100644
--- a/IceGlass/Info.plist
+++ b/IceGlass/Info.plist
@@ -22,5 +22,9 @@
public.app-category.utilities
LSMinimumSystemVersion
$(MACOSX_DEPLOYMENT_TARGET)
+ CFBundleIconFile
+ AppIcon
+ CFBundleIconName
+ AppIcon
diff --git a/IceGlass/Managers/AppSettings.swift b/IceGlass/Managers/AppSettings.swift
index 3399d16..277f2e5 100644
--- a/IceGlass/Managers/AppSettings.swift
+++ b/IceGlass/Managers/AppSettings.swift
@@ -17,11 +17,11 @@ class AppSettings: @unchecked Sendable {
private enum UserDefaultsKey {
static let launchAtLogin = "launchAtLogin"
- static let selectedTeam = "selectedTeam"
static let displayOption = "displayOption"
+ static let statusBarOption = "statusBarOption"
}
- /// Controls which days are shown in the menu and counted in the status bar
+ /// Controls which days are shown in the menu
enum DisplayOption: String, CaseIterable {
case yesterdayTodayTomorrow = "yesterdayTodayTomorrow"
case todayTomorrow = "todayTomorrow"
@@ -35,7 +35,6 @@ class AppSettings: @unchecked Sendable {
}
}
- /// Which date strings to include
func includedDates() -> Set {
switch self {
case .yesterdayTodayTomorrow:
@@ -48,24 +47,25 @@ class AppSettings: @unchecked Sendable {
}
}
- // Launch at login
- var launchAtLogin: Bool {
- get {
- UserDefaults.standard.bool(forKey: UserDefaultsKey.launchAtLogin)
- }
- set {
- UserDefaults.standard.set(newValue, forKey: UserDefaultsKey.launchAtLogin)
+ /// Controls what number shows next to the menu bar icon
+ enum StatusBarOption: String, CaseIterable {
+ case gameCount = "gameCount"
+ case gamesPlayed = "gamesPlayed"
+ case gamesPlayedTotal = "gamesPlayedTotal"
+
+ var title: String {
+ switch self {
+ case .gameCount: return "Game Count"
+ case .gamesPlayed: return "Games Played"
+ case .gamesPlayedTotal: return "Games Played / Total"
+ }
}
}
- // Selected team filter (nil = all teams)
- var selectedTeam: String? {
- get {
- UserDefaults.standard.string(forKey: UserDefaultsKey.selectedTeam)
- }
- set {
- UserDefaults.standard.set(newValue, forKey: UserDefaultsKey.selectedTeam)
- }
+ // Launch at login
+ var launchAtLogin: Bool {
+ get { UserDefaults.standard.bool(forKey: UserDefaultsKey.launchAtLogin) }
+ set { UserDefaults.standard.set(newValue, forKey: UserDefaultsKey.launchAtLogin) }
}
// Display option
@@ -77,9 +77,19 @@ class AppSettings: @unchecked Sendable {
}
return .yesterdayTodayTomorrow
}
- set {
- UserDefaults.standard.set(newValue.rawValue, forKey: UserDefaultsKey.displayOption)
+ set { UserDefaults.standard.set(newValue.rawValue, forKey: UserDefaultsKey.displayOption) }
+ }
+
+ // Status bar option
+ var statusBarOption: StatusBarOption {
+ get {
+ if let rawValue = UserDefaults.standard.string(forKey: UserDefaultsKey.statusBarOption),
+ let option = StatusBarOption(rawValue: rawValue) {
+ return option
+ }
+ return .gameCount
}
+ set { UserDefaults.standard.set(newValue.rawValue, forKey: UserDefaultsKey.statusBarOption) }
}
func updateLoginItem(enabled: Bool) {
diff --git a/IceGlass/Managers/MenuManager.swift b/IceGlass/Managers/MenuManager.swift
index b05e583..c379555 100644
--- a/IceGlass/Managers/MenuManager.swift
+++ b/IceGlass/Managers/MenuManager.swift
@@ -91,6 +91,23 @@ class MenuManager: @unchecked Sendable {
let monoFont = NSFont.monospacedSystemFont(ofSize: NSFont.systemFontSize, weight: .regular)
let boldFont = NSFont.systemFont(ofSize: NSFont.smallSystemFontSize, weight: .bold)
+ let roundItems = mainService.currentRoundSeriesItems
+ if !roundItems.isEmpty, let round = mainService.bracket?.currentRound {
+ let headerText = "ROUND \(round)"
+ let headerItem = NSMenuItem(title: headerText, action: nil, keyEquivalent: "")
+ headerItem.attributedTitle = NSAttributedString(
+ string: headerText,
+ attributes: [.font: boldFont]
+ )
+ menu.addItem(headerItem)
+
+ for item in roundItems {
+ menu.addItem(createRoundSeriesItem(for: item, font: monoFont))
+ }
+
+ menu.addItem(NSMenuItem.separator())
+ }
+
let gameDays = mainService.gamesByDate
if gameDays.isEmpty {
@@ -100,7 +117,7 @@ class MenuManager: @unchecked Sendable {
// Date header with game count
let dateLabel = Date.fullDateLabel(for: gameDay.date)
let gameCount = gameDay.games.count
- let headerText = "\(dateLabel) (\(gameCount) game\(gameCount == 1 ? "" : "s"))"
+ let headerText = "\(dateLabel) (\(gameCount))"
let headerItem = NSMenuItem(title: headerText, action: nil, keyEquivalent: "")
headerItem.attributedTitle = NSAttributedString(
string: headerText,
@@ -127,7 +144,6 @@ class MenuManager: @unchecked Sendable {
}
}
- // Separator between date groups
if index < gameDays.count - 1 {
menu.addItem(NSMenuItem.separator())
}
@@ -140,6 +156,8 @@ class MenuManager: @unchecked Sendable {
let displayMenuItem = NSMenuItem()
displayMenuItem.title = "Display Options"
let displaySubmenu = NSMenu()
+
+ // Date range options
for option in AppSettings.DisplayOption.allCases {
let item = NSMenuItem(
title: option.title,
@@ -151,6 +169,22 @@ class MenuManager: @unchecked Sendable {
item.state = option == settings.displayOption ? .on : .off
displaySubmenu.addItem(item)
}
+
+ displaySubmenu.addItem(NSMenuItem.separator())
+
+ // Status bar options
+ for option in AppSettings.StatusBarOption.allCases {
+ let item = NSMenuItem(
+ title: option.title,
+ action: #selector(changeStatusBarOption(_:)),
+ keyEquivalent: ""
+ )
+ item.target = self
+ item.representedObject = option.rawValue
+ item.state = option == settings.statusBarOption ? .on : .off
+ displaySubmenu.addItem(item)
+ }
+
displayMenuItem.submenu = displaySubmenu
menu.addItem(displayMenuItem)
@@ -174,36 +208,22 @@ class MenuManager: @unchecked Sendable {
menu.addItem(launchItem)
#if DEBUG
- // Developer menu
let devMenuItem = NSMenuItem()
devMenuItem.title = "Developer"
let devSubmenu = NSMenu()
devSubmenu.addItem(
- NSMenuItem(
- title: "Force Refresh",
- action: #selector(refreshStats),
- keyEquivalent: ""
- ).withTarget(self)
+ NSMenuItem(title: "Force Refresh", action: #selector(refreshStats), keyEquivalent: "").withTarget(self)
)
devSubmenu.addItem(
- NSMenuItem(
- title: "Test Game Start Notification",
- action: #selector(triggerTestGameStart),
- keyEquivalent: ""
- ).withTarget(self)
+ NSMenuItem(title: "Test Game Start Notification", action: #selector(triggerTestGameStart), keyEquivalent: "").withTarget(self)
)
devSubmenu.addItem(
- NSMenuItem(
- title: "Test Goal Scored Notification",
- action: #selector(triggerTestGoalScored),
- keyEquivalent: ""
- ).withTarget(self)
+ NSMenuItem(title: "Test Goal Scored Notification", action: #selector(triggerTestGoalScored), keyEquivalent: "").withTarget(self)
)
devMenuItem.submenu = devSubmenu
menu.addItem(devMenuItem)
#endif
- // Quit
menu.addItem(NSMenuItem.separator())
menu.addItem(NSMenuItem(title: "Quit", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q"))
@@ -238,12 +258,24 @@ class MenuManager: @unchecked Sendable {
NSWorkspace.shared.open(url)
}
+ @objc private func openSeriesPage(_ sender: NSMenuItem) {
+ guard let urlString = sender.representedObject as? String,
+ let url = URL(string: urlString) else { return }
+ NSWorkspace.shared.open(url)
+ }
+
@objc private func changeDisplayOption(_ sender: NSMenuItem) {
guard let rawValue = sender.representedObject as? String,
let option = AppSettings.DisplayOption(rawValue: rawValue) else { return }
settings.displayOption = option
- statusItemManager.updateGameCounts(mainService.gamesByDate)
- updateMenu()
+ mainService.updateUI()
+ }
+
+ @objc private func changeStatusBarOption(_ sender: NSMenuItem) {
+ guard let rawValue = sender.representedObject as? String,
+ let option = AppSettings.StatusBarOption(rawValue: rawValue) else { return }
+ settings.statusBarOption = option
+ mainService.updateUI()
}
@objc private func toggleLaunchAtLogin(_ sender: NSMenuItem) {
@@ -263,7 +295,13 @@ class MenuManager: @unchecked Sendable {
@objc private func triggerTestGoalScored() {
let notificationManager = NotificationManager.shared
if let game = mainService.gamesByDate.flatMap(\.games).first {
- notificationManager.notifyGoalScored(game, scoringTeam: game.homeTeam, bypassDedup: true)
+ let sampleScorer = GoalScorer(name: "J. Eriksson Ek", sweaterNumber: 14, strength: "PPG")
+ notificationManager.notifyGoalScored(
+ game,
+ scoringTeam: game.homeTeam,
+ scorer: sampleScorer,
+ bypassDedup: true
+ )
}
}
#endif
@@ -277,15 +315,16 @@ class MenuManager: @unchecked Sendable {
// MARK: - Menu Item Creation
private func createGameMenuItems(for game: Scoreboard.Game, font: NSFont) -> [NSMenuItem] {
- let title = " \(game.menuTitle)"
+ let prefix = game.gameType == 2
+ ? String(format: "#%4d ", game.seasonGameNumber)
+ : " "
+ let title = "\(prefix)\(game.menuTitle)"
- // Regular click item
let item = NSMenuItem(title: title, action: #selector(handleGameMenuClick(_:)), keyEquivalent: "")
item.target = self
item.representedObject = game
item.attributedTitle = NSAttributedString(string: title, attributes: [.font: font])
- // Alternate item for option-click (videocast)
let altItem = NSMenuItem(title: title, action: #selector(openGameStream(_:)), keyEquivalent: "")
altItem.target = self
altItem.representedObject = game
@@ -295,4 +334,53 @@ class MenuManager: @unchecked Sendable {
return [item, altItem]
}
+
+ private func createRoundSeriesItem(for roundItem: MainService.RoundSeriesItem, font: NSFont) -> NSMenuItem {
+ let series = roundItem.series
+ let top = series.topSeedTeam?.abbrev ?? "TBD"
+ let bottom = series.bottomSeedTeam?.abbrev ?? "TBD"
+ let matchup = "\(bottom) @ \(top)"
+ let score = "\(series.bottomSeedWins)-\(series.topSeedWins)"
+
+ let statusTag: String
+ let trailing: String
+ if let winner = series.winner {
+ statusTag = "(Final \(winner) wins)"
+ trailing = ""
+ } else if let n = series.nextGameNumber {
+ statusTag = "(Game \(n))"
+ trailing = roundItem.nextGame.map { Self.nextGameLabel(for: $0) } ?? ""
+ } else {
+ statusTag = ""
+ trailing = ""
+ }
+ let tagColumn = statusTag.padding(toLength: 20, withPad: " ", startingAt: 0)
+ let title = " \(matchup) \(score) \(tagColumn)\(trailing)"
+
+ let item = NSMenuItem(title: title, action: #selector(openSeriesPage(_:)), keyEquivalent: "")
+ item.target = self
+ item.representedObject = series.fullSeriesUrl
+ item.attributedTitle = NSAttributedString(string: title, attributes: [.font: font])
+ return item
+ }
+
+ private static func nextGameLabel(for game: Scoreboard.Game) -> String {
+ let dayLabel: String
+ switch game.gameDate {
+ case Date.todayET: dayLabel = "Today"
+ case Date.tomorrowET: dayLabel = "Tomorrow"
+ case Date.yesterdayET: dayLabel = "Yesterday"
+ default:
+ let formatter = DateFormatter()
+ formatter.dateFormat = "yyyy-MM-dd"
+ formatter.timeZone = TimeZone(identifier: "America/New_York")
+ if let date = formatter.date(from: game.gameDate) {
+ dayLabel = date.formatDateET(format: "EEE")
+ } else {
+ dayLabel = ""
+ }
+ }
+ let time = game.startTimeET.trimmingCharacters(in: .whitespaces)
+ return "\(dayLabel) \(time)"
+ }
}
diff --git a/IceGlass/Managers/NotificationManager.swift b/IceGlass/Managers/NotificationManager.swift
index 8b7b0cd..722c4b1 100644
--- a/IceGlass/Managers/NotificationManager.swift
+++ b/IceGlass/Managers/NotificationManager.swift
@@ -92,7 +92,12 @@ class NotificationManager: @unchecked Sendable {
// MARK: - Goal Scored
- func notifyGoalScored(_ game: Scoreboard.Game, scoringTeam: Scoreboard.Game.Team, bypassDedup: Bool = false) {
+ func notifyGoalScored(
+ _ game: Scoreboard.Game,
+ scoringTeam: Scoreboard.Game.Team,
+ scorer: GoalScorer? = nil,
+ bypassDedup: Bool = false
+ ) {
let awayScore = game.awayTeam.score ?? 0
let homeScore = game.homeTeam.score ?? 0
let key = "\(game.id)-\(awayScore)-\(homeScore)"
@@ -104,6 +109,9 @@ class NotificationManager: @unchecked Sendable {
let content = UNMutableNotificationContent()
content.title = "\(scoringTeam.abbrev) Goal!"
+ if let scorer = scorer {
+ content.subtitle = scorer.displayLine
+ }
content.body = "\(game.awayTeam.abbrev) \(awayScore) : \(game.homeTeam.abbrev) \(homeScore)"
content.sound = UNNotificationSound(named: UNNotificationSoundName("Glass"))
content.interruptionLevel = .active
diff --git a/IceGlass/Managers/StatusItemManager.swift b/IceGlass/Managers/StatusItemManager.swift
index 01aaa8c..ce9bceb 100644
--- a/IceGlass/Managers/StatusItemManager.swift
+++ b/IceGlass/Managers/StatusItemManager.swift
@@ -39,15 +39,23 @@ final class StatusItemManager: @unchecked Sendable {
guard let baseImage = NSImage(named: NSImage.Name("NHLShield")) else {
button.title = "NHL"
+ button.image = nil
return
}
- let pointSize = button.frame.size.height > 0 ? button.frame.size.height : 22
- let resizedImage = NSImage(size: NSSize(width: pointSize, height: pointSize))
+ let height = button.frame.size.height > 0 ? button.frame.size.height : 22
+
+ // Use full menu bar height; let width follow natural aspect ratio
+ let srcW = baseImage.size.width
+ let srcH = baseImage.size.height
+ let scale = height / srcH
+ let width = srcW * scale
+
+ let resizedImage = NSImage(size: NSSize(width: width, height: height))
resizedImage.lockFocus()
baseImage.draw(
- in: NSRect(x: 0, y: 0, width: pointSize, height: pointSize),
- from: NSRect(x: 0, y: 0, width: baseImage.size.width, height: baseImage.size.height),
+ in: NSRect(x: 0, y: 0, width: width, height: height),
+ from: NSRect(x: 0, y: 0, width: srcW, height: srcH),
operation: .copy,
fraction: 1.0
)
@@ -58,16 +66,10 @@ final class StatusItemManager: @unchecked Sendable {
}
}
- /// Update status bar text with per-day game counts (e.g. "6/10/9" or "10/9")
- func updateGameCounts(_ gameDays: [Scoreboard.GameDay]) {
+ func updateStatusText(_ text: String) {
Task { @MainActor in
guard let button = self.statusItem?.button else { return }
- if gameDays.isEmpty {
- button.title = ""
- return
- }
- let counts = gameDays.map { "\($0.games.count)" }.joined(separator: "/")
- button.title = " \(counts)"
+ button.title = text.isEmpty ? "" : " \(text)"
}
}
}
diff --git a/IceGlass/Models/BracketModel.swift b/IceGlass/Models/BracketModel.swift
new file mode 100644
index 0000000..c0021ed
--- /dev/null
+++ b/IceGlass/Models/BracketModel.swift
@@ -0,0 +1,74 @@
+//
+// BracketModel.swift
+// IceGlass
+//
+// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
+//
+
+import Foundation
+
+struct PlayoffBracket: Codable {
+ let series: [Series]
+
+ struct Series: Codable {
+ let seriesUrl: String?
+ let seriesLetter: String
+ let playoffRound: Int
+ let seriesTitle: String
+ let topSeedTeam: Team?
+ let bottomSeedTeam: Team?
+ let topSeedWins: Int
+ let bottomSeedWins: Int
+
+ struct Team: Codable {
+ let abbrev: String
+ }
+
+ /// True once both teams are known (i.e. the series is actually matched up).
+ var isMatched: Bool { topSeedTeam != nil && bottomSeedTeam != nil }
+
+ var isOver: Bool { topSeedWins == 4 || bottomSeedWins == 4 }
+
+ /// 1-based game number of the next unplayed game in the series (nil if series is over).
+ var nextGameNumber: Int? {
+ isOver ? nil : topSeedWins + bottomSeedWins + 1
+ }
+
+ /// Abbrev of whichever side has reached 4 wins, nil if series ongoing.
+ var winner: String? {
+ guard isOver else { return nil }
+ return topSeedWins == 4 ? topSeedTeam?.abbrev : bottomSeedTeam?.abbrev
+ }
+
+ /// Absolute URL for the NHL.com series page, or nil if the bracket doesn't provide one.
+ var fullSeriesUrl: String? {
+ seriesUrl.map { "https://www.nhl.com\($0)" }
+ }
+
+ func involves(away: String, home: String) -> Bool {
+ guard let top = topSeedTeam, let bottom = bottomSeedTeam else { return false }
+ let pair = Set([away, home])
+ return pair == Set([top.abbrev, bottom.abbrev])
+ }
+ }
+
+ /// Lowest round number that still has at least one matched, live series.
+ var currentRound: Int? {
+ series
+ .filter { $0.isMatched && !$0.isOver }
+ .map(\.playoffRound)
+ .min()
+ }
+
+ /// All matched series in the current round.
+ var currentRoundSeries: [Series] {
+ guard let round = currentRound else { return [] }
+ return series
+ .filter { $0.playoffRound == round && $0.isMatched }
+ .sorted { $0.seriesLetter < $1.seriesLetter }
+ }
+
+ func series(for game: Scoreboard.Game) -> Series? {
+ series.first { $0.involves(away: game.awayTeam.abbrev, home: game.homeTeam.abbrev) }
+ }
+}
diff --git a/IceGlass/Models/NHLTeam.swift b/IceGlass/Models/NHLTeam.swift
deleted file mode 100644
index 221a87f..0000000
--- a/IceGlass/Models/NHLTeam.swift
+++ /dev/null
@@ -1,82 +0,0 @@
-//
-// NHLTeam.swift
-// IceGlass
-//
-// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
-//
-
-import Foundation
-
-enum NHLTeam: String, CaseIterable {
- case ana = "ANA"
- case bos = "BOS"
- case buf = "BUF"
- case car = "CAR"
- case cbj = "CBJ"
- case cgy = "CGY"
- case chi = "CHI"
- case col = "COL"
- case dal = "DAL"
- case det = "DET"
- case edm = "EDM"
- case fla = "FLA"
- case lak = "LAK"
- case min = "MIN"
- case mtl = "MTL"
- case njd = "NJD"
- case nsh = "NSH"
- case nyi = "NYI"
- case nyr = "NYR"
- case ott = "OTT"
- case phi = "PHI"
- case pit = "PIT"
- case sea = "SEA"
- case sjs = "SJS"
- case stl = "STL"
- case tbl = "TBL"
- case tor = "TOR"
- case uta = "UTA"
- case van = "VAN"
- case vgk = "VGK"
- case wpg = "WPG"
- case wsh = "WSH"
-
- var abbreviation: String { rawValue }
-
- var fullName: String {
- switch self {
- case .ana: return "Anaheim Ducks"
- case .bos: return "Boston Bruins"
- case .buf: return "Buffalo Sabres"
- case .car: return "Carolina Hurricanes"
- case .cbj: return "Columbus Blue Jackets"
- case .cgy: return "Calgary Flames"
- case .chi: return "Chicago Blackhawks"
- case .col: return "Colorado Avalanche"
- case .dal: return "Dallas Stars"
- case .det: return "Detroit Red Wings"
- case .edm: return "Edmonton Oilers"
- case .fla: return "Florida Panthers"
- case .lak: return "Los Angeles Kings"
- case .min: return "Minnesota Wild"
- case .mtl: return "Montreal Canadiens"
- case .njd: return "New Jersey Devils"
- case .nsh: return "Nashville Predators"
- case .nyi: return "New York Islanders"
- case .nyr: return "New York Rangers"
- case .ott: return "Ottawa Senators"
- case .phi: return "Philadelphia Flyers"
- case .pit: return "Pittsburgh Penguins"
- case .sea: return "Seattle Kraken"
- case .sjs: return "San Jose Sharks"
- case .stl: return "St. Louis Blues"
- case .tbl: return "Tampa Bay Lightning"
- case .tor: return "Toronto Maple Leafs"
- case .uta: return "Utah Hockey Club"
- case .van: return "Vancouver Canucks"
- case .vgk: return "Vegas Golden Knights"
- case .wpg: return "Winnipeg Jets"
- case .wsh: return "Washington Capitals"
- }
- }
-}
diff --git a/IceGlass/Models/PlayByPlayModel.swift b/IceGlass/Models/PlayByPlayModel.swift
new file mode 100644
index 0000000..6bbc242
--- /dev/null
+++ b/IceGlass/Models/PlayByPlayModel.swift
@@ -0,0 +1,94 @@
+//
+// PlayByPlayModel.swift
+// IceGlass
+//
+// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
+//
+
+import Foundation
+
+struct PlayByPlay: Codable {
+ let awayTeam: Team
+ let homeTeam: Team
+ let plays: [Play]
+ let rosterSpots: [RosterSpot]
+
+ struct Team: Codable {
+ let id: Int
+ let abbrev: String
+ }
+
+ struct Play: Codable {
+ let typeDescKey: String
+ let situationCode: String?
+ let details: Details?
+
+ struct Details: Codable {
+ let scoringPlayerId: Int?
+ let eventOwnerTeamId: Int?
+ let awayScore: Int?
+ let homeScore: Int?
+ }
+ }
+
+ struct RosterSpot: Codable {
+ let teamId: Int
+ let playerId: Int
+ let firstName: LocalizedString
+ let lastName: LocalizedString
+ let sweaterNumber: Int
+
+ struct LocalizedString: Codable {
+ let `default`: String
+ }
+ }
+
+ /// Find the goal play whose trailing score matches the given totals exactly.
+ /// Returns nil if play-by-play hasn't caught up with the scoreboard yet —
+ /// safer than guessing, since a stale fallback could name the previous scorer.
+ func goal(matchingAwayScore awayScore: Int, homeScore: Int) -> Play? {
+ plays.last { play in
+ play.typeDescKey == "goal"
+ && play.details?.awayScore == awayScore
+ && play.details?.homeScore == homeScore
+ }
+ }
+
+ func player(id: Int) -> RosterSpot? {
+ rosterSpots.first { $0.playerId == id }
+ }
+
+ /// Derived strength tag for a goal ("PPG", "SHG", "EN"), or nil for even-strength.
+ /// `situationCode` is 4 digits: awayGoalie, awaySkaters, homeSkaters, homeGoalie.
+ static func strengthTag(situationCode: String?, scoringTeamIsAway: Bool) -> String? {
+ guard let code = situationCode, code.count == 4 else { return nil }
+ let digits = code.compactMap { $0.wholeNumberValue }
+ guard digits.count == 4 else { return nil }
+ let awayGoalie = digits[0]
+ let awaySkaters = digits[1]
+ let homeSkaters = digits[2]
+ let homeGoalie = digits[3]
+
+ let scoringSkaters = scoringTeamIsAway ? awaySkaters : homeSkaters
+ let opposingSkaters = scoringTeamIsAway ? homeSkaters : awaySkaters
+ let opposingGoalie = scoringTeamIsAway ? homeGoalie : awayGoalie
+
+ if opposingGoalie == 0 { return "EN" }
+ if scoringSkaters > opposingSkaters { return "PPG" }
+ if scoringSkaters < opposingSkaters { return "SHG" }
+ return nil
+ }
+}
+
+struct GoalScorer {
+ let name: String
+ let sweaterNumber: Int
+ let strength: String?
+
+ /// "#14 J. Eriksson Ek (PPG)" — strength suffix omitted for even-strength goals.
+ var displayLine: String {
+ let head = "#\(sweaterNumber) \(name)"
+ if let strength = strength { return "\(head) (\(strength))" }
+ return head
+ }
+}
diff --git a/IceGlass/Models/ScoreboardModel.swift b/IceGlass/Models/ScoreboardModel.swift
index 2c4089c..16fd62f 100644
--- a/IceGlass/Models/ScoreboardModel.swift
+++ b/IceGlass/Models/ScoreboardModel.swift
@@ -73,30 +73,58 @@ struct Scoreboard: Codable {
"https://videocast.nhl.com/game/\(id)/usnded?autoplay=true"
}
- /// Time string in ET for display (e.g., "7:00 PM")
+ /// Time string in ET, right-aligned to 8 chars (e.g. " 9:30 PM", "10:00 PM")
var startTimeET: String {
- date.formatDateET(format: "h:mm a")
+ let raw = date.formatDateET(format: "h:mm a")
+ return raw.count < 8
+ ? String(repeating: " ", count: 8 - raw.count) + raw
+ : raw
}
- /// Formatted menu title: "NYR @ WAS 0:2 (FINAL)" or "DAL @ TOR Today @ 7:30 PM"
+ /// Formatted menu title:
+ /// "NYR @ WAS 0: 2 9:30 PM" (finished/live — padded score + time)
+ /// "DAL @ TOR 7:30 PM" (future — no score gap)
var menuTitle: String {
let state = parsedGameState
let matchup = "\(awayTeam.abbrev) @ \(homeTeam.abbrev)"
if state.isFuture {
- let isToday = gameDate == Date.todayET
- let prefix = isToday ? "Today @ " : ""
- return "\(matchup) \(prefix)\(startTimeET)"
+ return "\(matchup) \(startTimeET)"
}
- // Has scores
- let score = "\(awayTeam.score ?? 0):\(homeTeam.score ?? 0)"
- return "\(matchup) \(score) (\(gameState))"
+ let aScore = String(format: "%2d", awayTeam.score ?? 0)
+ let hScore = String(format: "%-2d", homeTeam.score ?? 0)
+ return "\(matchup) \(aScore):\(hScore) \(startTimeET)"
}
- /// Whether this game involves a specific team
- func involves(team abbrev: String) -> Bool {
- awayTeam.abbrev == abbrev || homeTeam.abbrev == abbrev
+ /// Sequential game number encoded in the last 4 digits of `id`.
+ /// Regular season: 1…~1312. Playoffs: 111–417 (`RSG` round/series/game).
+ var seasonGameNumber: Int { id % 10_000 }
+
+ /// Parsed playoff context; nil for non-playoff games.
+ var playoffContext: PlayoffContext? {
+ guard gameType == 3 else { return nil }
+ let n = id % 1000
+ return PlayoffContext(
+ round: n / 100,
+ seriesInRound: (n / 10) % 10,
+ gameInSeries: n % 10
+ )
+ }
+
+ struct PlayoffContext: Equatable {
+ let round: Int
+ let seriesInRound: Int
+ let gameInSeries: Int
+
+ /// A…O by cumulative position (R1 S1 = A, R1 S8 = H, R2 S1 = I, …, R4 S1 = O).
+ var seriesLetter: String {
+ let roundStartIndex = [0, 0, 8, 12, 14]
+ guard round >= 1, round <= 4 else { return "" }
+ let index = roundStartIndex[round] + (seriesInRound - 1)
+ guard index >= 0, index < 15 else { return "" }
+ return String(UnicodeScalar(65 + index)!)
+ }
}
}
}
diff --git a/IceGlass/Models/StandingsModel.swift b/IceGlass/Models/StandingsModel.swift
new file mode 100644
index 0000000..7929c06
--- /dev/null
+++ b/IceGlass/Models/StandingsModel.swift
@@ -0,0 +1,35 @@
+//
+// StandingsModel.swift
+// IceGlass
+//
+// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
+//
+
+import Foundation
+
+struct Standings: Codable {
+ let standings: [TeamStanding]
+
+ struct LocalizedString: Codable {
+ let `default`: String
+ }
+
+ struct TeamStanding: Codable {
+ let teamAbbrev: LocalizedString
+ let teamLogo: String
+ let gamesPlayed: Int
+ let wins: Int
+ let losses: Int
+ let otLosses: Int
+ let points: Int
+ let seasonId: Int
+ }
+
+ /// Total unique games played across the league (each game counted once)
+ var totalGamesPlayed: Int {
+ standings.reduce(0) { $0 + $1.gamesPlayed } / 2
+ }
+
+ /// Total regular season games: 32 teams * 82 games / 2
+ static let totalRegularSeasonGames = 1312
+}
diff --git a/IceGlass/Services/MainService.swift b/IceGlass/Services/MainService.swift
index 9dd54ec..56857bc 100644
--- a/IceGlass/Services/MainService.swift
+++ b/IceGlass/Services/MainService.swift
@@ -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()
+ }
}
}
diff --git a/README.md b/README.md
index 6800ceb..ea5f791 100644
--- a/README.md
+++ b/README.md
@@ -6,6 +6,8 @@ A macOS menu bar app for NHL game situational awareness.
- NHL shield icon in the menu bar with game count
- Shows games from yesterday, today, and tomorrow grouped by date (configurable)
+- Regular-season rows show league-wide game number (`#547 NYR @ WAS …`)
+- During playoffs, a ROUND section lists every active series with its series score, next game-in-series number, and upcoming tip-off time
- Game format: `NYR @ WAS 0:2 (FINAL)` / `DAL @ TOR Today @ 7:30 PM`
- Click a game to open NHL GameCenter; option-click for NHL Videocast
- Goal scored notifications with scoring team logo
diff --git a/project.yml b/project.yml
index 3dfe69b..7bab8b0 100644
--- a/project.yml
+++ b/project.yml
@@ -27,12 +27,14 @@ targets:
CURRENT_PROJECT_VERSION: "1"
INFOPLIST_FILE: IceGlass/Info.plist
CODE_SIGN_ENTITLEMENTS: IceGlass.entitlements
+ DEVELOPMENT_TEAM: C32Z8JNLG6
SWIFT_VERSION: "6.0"
MACOSX_DEPLOYMENT_TARGET: "13.0"
PRODUCT_NAME: IceGlass
COMBINE_HIDPI_IMAGES: true
SWIFT_STRICT_CONCURRENCY: complete
ENABLE_USER_SCRIPT_SANDBOXING: false
+ ENABLE_HARDENED_RUNTIME: true
configs:
Debug:
SWIFT_ACTIVE_COMPILATION_CONDITIONS: DEBUG