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