Add playoff round view, game numbers, goal scorer notifications, standings
- Fetch NHL standings and surface league/season game counts in the menu bar
- Prefix regular-season rows with the league-wide game number (from gameId)
- New ROUND section shows each active playoff series (matchup, series score,
next game number + time) derived from /v1/playoff-bracket; rows always open
the NHL series page so completed series remain clickable
- Goal notifications include scorer sweater, abbreviated name, and strength
(PPG/SHG/EN), resolved via /v1/gamecenter/{id}/play-by-play
- Drop the per-team filter submenu and NHLTeam enum
- Regenerate AppIcon with the full 10-size macOS set (alpha preserved) so
notifications render the app icon correctly; rename the iOS marketing PNG
to icon-ios-1024.png
- gitignore .claude/ local tooling settings
@@ -17,3 +17,6 @@ xcuserdata/
|
|||||||
|
|
||||||
# Project-specific
|
# Project-specific
|
||||||
IceGlass.xcodeproj/
|
IceGlass.xcodeproj/
|
||||||
|
|
||||||
|
# Claude Code local settings
|
||||||
|
.claude/
|
||||||
|
|||||||
@@ -2,6 +2,11 @@
|
|||||||
|
|
||||||
## April 2026
|
## 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
|
- Initial project setup with macOS menu bar app
|
||||||
- NHL scoreboard API integration showing yesterday/today/tomorrow games
|
- NHL scoreboard API integration showing yesterday/today/tomorrow games
|
||||||
- Dynamic polling based on game state (7s live, 10min game day, 1hr idle)
|
- Dynamic polling based on game state (7s live, 10min game day, 1hr idle)
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ NHL operates on Eastern Time. Date extensions handle timezone conversions:
|
|||||||
IceGlass/
|
IceGlass/
|
||||||
├── Services/ - Core business logic (MainService, ApiService, PollingInterval)
|
├── Services/ - Core business logic (MainService, ApiService, PollingInterval)
|
||||||
├── Managers/ - UI coordination (StatusItemManager, MenuManager, NotificationManager, AppSettings)
|
├── 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
|
├── Extensions/ - Date/Timer/NSMenuItem/TimeInterval helpers
|
||||||
├── Lib/ - Utilities (IceGlassLogger, AppTerminator)
|
├── Lib/ - Utilities (IceGlassLogger, AppTerminator)
|
||||||
├── About/ - About window (AboutWindow with IndieAbout)
|
├── About/ - About window (AboutWindow with IndieAbout)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
import UserNotifications
|
import UserNotifications
|
||||||
import AppKit
|
import AppKit
|
||||||
|
import CoreServices
|
||||||
|
|
||||||
class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDelegate {
|
class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDelegate {
|
||||||
private let logger = IceGlassLogger(
|
private let logger = IceGlassLogger(
|
||||||
@@ -20,8 +21,12 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele
|
|||||||
logger.info("applicationDidFinishLaunching")
|
logger.info("applicationDidFinishLaunching")
|
||||||
UNUserNotificationCenter.current().delegate = self
|
UNUserNotificationCenter.current().delegate = self
|
||||||
|
|
||||||
// Set app icon explicitly (LSUIElement apps may not pick it up automatically)
|
// Force re-register with Launch Services to refresh cached icon
|
||||||
if let icon = NSImage(named: "AppIcon") {
|
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
|
NSApp.applicationIconImage = icon
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,112 +1,10 @@
|
|||||||
{
|
{
|
||||||
"images": [
|
"images": [
|
||||||
{
|
{
|
||||||
"filename": "icon-20@2x.png",
|
"filename": "icon-ios-1024.png",
|
||||||
"idiom": "iphone",
|
"idiom": "universal",
|
||||||
"size": "20x20",
|
"platform": "ios",
|
||||||
"scale": "2x"
|
"size": "1024x1024"
|
||||||
},
|
|
||||||
{
|
|
||||||
"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-mac-16.png",
|
"filename": "icon-mac-16.png",
|
||||||
@@ -173,4 +71,4 @@
|
|||||||
"author": "xcode",
|
"author": "xcode",
|
||||||
"version": 1
|
"version": 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Before Width: | Height: | Size: 1006 B |
|
Before Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 9.8 KiB |
|
Before Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 8.7 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 8.0 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 211 KiB After Width: | Height: | Size: 211 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 709 B After Width: | Height: | Size: 888 B |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 92 KiB After Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 92 KiB After Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 211 KiB After Width: | Height: | Size: 232 KiB |
@@ -22,5 +22,9 @@
|
|||||||
<string>public.app-category.utilities</string>
|
<string>public.app-category.utilities</string>
|
||||||
<key>LSMinimumSystemVersion</key>
|
<key>LSMinimumSystemVersion</key>
|
||||||
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
|
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
|
||||||
|
<key>CFBundleIconFile</key>
|
||||||
|
<string>AppIcon</string>
|
||||||
|
<key>CFBundleIconName</key>
|
||||||
|
<string>AppIcon</string>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -17,11 +17,11 @@ class AppSettings: @unchecked Sendable {
|
|||||||
|
|
||||||
private enum UserDefaultsKey {
|
private enum UserDefaultsKey {
|
||||||
static let launchAtLogin = "launchAtLogin"
|
static let launchAtLogin = "launchAtLogin"
|
||||||
static let selectedTeam = "selectedTeam"
|
|
||||||
static let displayOption = "displayOption"
|
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 {
|
enum DisplayOption: String, CaseIterable {
|
||||||
case yesterdayTodayTomorrow = "yesterdayTodayTomorrow"
|
case yesterdayTodayTomorrow = "yesterdayTodayTomorrow"
|
||||||
case todayTomorrow = "todayTomorrow"
|
case todayTomorrow = "todayTomorrow"
|
||||||
@@ -35,7 +35,6 @@ class AppSettings: @unchecked Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Which date strings to include
|
|
||||||
func includedDates() -> Set<String> {
|
func includedDates() -> Set<String> {
|
||||||
switch self {
|
switch self {
|
||||||
case .yesterdayTodayTomorrow:
|
case .yesterdayTodayTomorrow:
|
||||||
@@ -48,24 +47,25 @@ class AppSettings: @unchecked Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Launch at login
|
/// Controls what number shows next to the menu bar icon
|
||||||
var launchAtLogin: Bool {
|
enum StatusBarOption: String, CaseIterable {
|
||||||
get {
|
case gameCount = "gameCount"
|
||||||
UserDefaults.standard.bool(forKey: UserDefaultsKey.launchAtLogin)
|
case gamesPlayed = "gamesPlayed"
|
||||||
}
|
case gamesPlayedTotal = "gamesPlayedTotal"
|
||||||
set {
|
|
||||||
UserDefaults.standard.set(newValue, forKey: UserDefaultsKey.launchAtLogin)
|
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)
|
// Launch at login
|
||||||
var selectedTeam: String? {
|
var launchAtLogin: Bool {
|
||||||
get {
|
get { UserDefaults.standard.bool(forKey: UserDefaultsKey.launchAtLogin) }
|
||||||
UserDefaults.standard.string(forKey: UserDefaultsKey.selectedTeam)
|
set { UserDefaults.standard.set(newValue, forKey: UserDefaultsKey.launchAtLogin) }
|
||||||
}
|
|
||||||
set {
|
|
||||||
UserDefaults.standard.set(newValue, forKey: UserDefaultsKey.selectedTeam)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Display option
|
// Display option
|
||||||
@@ -77,9 +77,19 @@ class AppSettings: @unchecked Sendable {
|
|||||||
}
|
}
|
||||||
return .yesterdayTodayTomorrow
|
return .yesterdayTodayTomorrow
|
||||||
}
|
}
|
||||||
set {
|
set { UserDefaults.standard.set(newValue.rawValue, forKey: UserDefaultsKey.displayOption) }
|
||||||
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) {
|
func updateLoginItem(enabled: Bool) {
|
||||||
|
|||||||
@@ -91,6 +91,23 @@ class MenuManager: @unchecked Sendable {
|
|||||||
let monoFont = NSFont.monospacedSystemFont(ofSize: NSFont.systemFontSize, weight: .regular)
|
let monoFont = NSFont.monospacedSystemFont(ofSize: NSFont.systemFontSize, weight: .regular)
|
||||||
let boldFont = NSFont.systemFont(ofSize: NSFont.smallSystemFontSize, weight: .bold)
|
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
|
let gameDays = mainService.gamesByDate
|
||||||
|
|
||||||
if gameDays.isEmpty {
|
if gameDays.isEmpty {
|
||||||
@@ -100,7 +117,7 @@ class MenuManager: @unchecked Sendable {
|
|||||||
// Date header with game count
|
// Date header with game count
|
||||||
let dateLabel = Date.fullDateLabel(for: gameDay.date)
|
let dateLabel = Date.fullDateLabel(for: gameDay.date)
|
||||||
let gameCount = gameDay.games.count
|
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: "")
|
let headerItem = NSMenuItem(title: headerText, action: nil, keyEquivalent: "")
|
||||||
headerItem.attributedTitle = NSAttributedString(
|
headerItem.attributedTitle = NSAttributedString(
|
||||||
string: headerText,
|
string: headerText,
|
||||||
@@ -127,7 +144,6 @@ class MenuManager: @unchecked Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Separator between date groups
|
|
||||||
if index < gameDays.count - 1 {
|
if index < gameDays.count - 1 {
|
||||||
menu.addItem(NSMenuItem.separator())
|
menu.addItem(NSMenuItem.separator())
|
||||||
}
|
}
|
||||||
@@ -140,6 +156,8 @@ class MenuManager: @unchecked Sendable {
|
|||||||
let displayMenuItem = NSMenuItem()
|
let displayMenuItem = NSMenuItem()
|
||||||
displayMenuItem.title = "Display Options"
|
displayMenuItem.title = "Display Options"
|
||||||
let displaySubmenu = NSMenu()
|
let displaySubmenu = NSMenu()
|
||||||
|
|
||||||
|
// Date range options
|
||||||
for option in AppSettings.DisplayOption.allCases {
|
for option in AppSettings.DisplayOption.allCases {
|
||||||
let item = NSMenuItem(
|
let item = NSMenuItem(
|
||||||
title: option.title,
|
title: option.title,
|
||||||
@@ -151,6 +169,22 @@ class MenuManager: @unchecked Sendable {
|
|||||||
item.state = option == settings.displayOption ? .on : .off
|
item.state = option == settings.displayOption ? .on : .off
|
||||||
displaySubmenu.addItem(item)
|
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
|
displayMenuItem.submenu = displaySubmenu
|
||||||
menu.addItem(displayMenuItem)
|
menu.addItem(displayMenuItem)
|
||||||
|
|
||||||
@@ -174,36 +208,22 @@ class MenuManager: @unchecked Sendable {
|
|||||||
menu.addItem(launchItem)
|
menu.addItem(launchItem)
|
||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
// Developer menu
|
|
||||||
let devMenuItem = NSMenuItem()
|
let devMenuItem = NSMenuItem()
|
||||||
devMenuItem.title = "Developer"
|
devMenuItem.title = "Developer"
|
||||||
let devSubmenu = NSMenu()
|
let devSubmenu = NSMenu()
|
||||||
devSubmenu.addItem(
|
devSubmenu.addItem(
|
||||||
NSMenuItem(
|
NSMenuItem(title: "Force Refresh", action: #selector(refreshStats), keyEquivalent: "").withTarget(self)
|
||||||
title: "Force Refresh",
|
|
||||||
action: #selector(refreshStats),
|
|
||||||
keyEquivalent: ""
|
|
||||||
).withTarget(self)
|
|
||||||
)
|
)
|
||||||
devSubmenu.addItem(
|
devSubmenu.addItem(
|
||||||
NSMenuItem(
|
NSMenuItem(title: "Test Game Start Notification", action: #selector(triggerTestGameStart), keyEquivalent: "").withTarget(self)
|
||||||
title: "Test Game Start Notification",
|
|
||||||
action: #selector(triggerTestGameStart),
|
|
||||||
keyEquivalent: ""
|
|
||||||
).withTarget(self)
|
|
||||||
)
|
)
|
||||||
devSubmenu.addItem(
|
devSubmenu.addItem(
|
||||||
NSMenuItem(
|
NSMenuItem(title: "Test Goal Scored Notification", action: #selector(triggerTestGoalScored), keyEquivalent: "").withTarget(self)
|
||||||
title: "Test Goal Scored Notification",
|
|
||||||
action: #selector(triggerTestGoalScored),
|
|
||||||
keyEquivalent: ""
|
|
||||||
).withTarget(self)
|
|
||||||
)
|
)
|
||||||
devMenuItem.submenu = devSubmenu
|
devMenuItem.submenu = devSubmenu
|
||||||
menu.addItem(devMenuItem)
|
menu.addItem(devMenuItem)
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
// Quit
|
|
||||||
menu.addItem(NSMenuItem.separator())
|
menu.addItem(NSMenuItem.separator())
|
||||||
menu.addItem(NSMenuItem(title: "Quit", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q"))
|
menu.addItem(NSMenuItem(title: "Quit", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q"))
|
||||||
|
|
||||||
@@ -238,12 +258,24 @@ class MenuManager: @unchecked Sendable {
|
|||||||
NSWorkspace.shared.open(url)
|
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) {
|
@objc private func changeDisplayOption(_ sender: NSMenuItem) {
|
||||||
guard let rawValue = sender.representedObject as? String,
|
guard let rawValue = sender.representedObject as? String,
|
||||||
let option = AppSettings.DisplayOption(rawValue: rawValue) else { return }
|
let option = AppSettings.DisplayOption(rawValue: rawValue) else { return }
|
||||||
settings.displayOption = option
|
settings.displayOption = option
|
||||||
statusItemManager.updateGameCounts(mainService.gamesByDate)
|
mainService.updateUI()
|
||||||
updateMenu()
|
}
|
||||||
|
|
||||||
|
@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) {
|
@objc private func toggleLaunchAtLogin(_ sender: NSMenuItem) {
|
||||||
@@ -263,7 +295,13 @@ class MenuManager: @unchecked Sendable {
|
|||||||
@objc private func triggerTestGoalScored() {
|
@objc private func triggerTestGoalScored() {
|
||||||
let notificationManager = NotificationManager.shared
|
let notificationManager = NotificationManager.shared
|
||||||
if let game = mainService.gamesByDate.flatMap(\.games).first {
|
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
|
#endif
|
||||||
@@ -277,15 +315,16 @@ class MenuManager: @unchecked Sendable {
|
|||||||
// MARK: - Menu Item Creation
|
// MARK: - Menu Item Creation
|
||||||
|
|
||||||
private func createGameMenuItems(for game: Scoreboard.Game, font: NSFont) -> [NSMenuItem] {
|
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: "")
|
let item = NSMenuItem(title: title, action: #selector(handleGameMenuClick(_:)), keyEquivalent: "")
|
||||||
item.target = self
|
item.target = self
|
||||||
item.representedObject = game
|
item.representedObject = game
|
||||||
item.attributedTitle = NSAttributedString(string: title, attributes: [.font: font])
|
item.attributedTitle = NSAttributedString(string: title, attributes: [.font: font])
|
||||||
|
|
||||||
// Alternate item for option-click (videocast)
|
|
||||||
let altItem = NSMenuItem(title: title, action: #selector(openGameStream(_:)), keyEquivalent: "")
|
let altItem = NSMenuItem(title: title, action: #selector(openGameStream(_:)), keyEquivalent: "")
|
||||||
altItem.target = self
|
altItem.target = self
|
||||||
altItem.representedObject = game
|
altItem.representedObject = game
|
||||||
@@ -295,4 +334,53 @@ class MenuManager: @unchecked Sendable {
|
|||||||
|
|
||||||
return [item, altItem]
|
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)"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,7 +92,12 @@ class NotificationManager: @unchecked Sendable {
|
|||||||
|
|
||||||
// MARK: - Goal Scored
|
// 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 awayScore = game.awayTeam.score ?? 0
|
||||||
let homeScore = game.homeTeam.score ?? 0
|
let homeScore = game.homeTeam.score ?? 0
|
||||||
let key = "\(game.id)-\(awayScore)-\(homeScore)"
|
let key = "\(game.id)-\(awayScore)-\(homeScore)"
|
||||||
@@ -104,6 +109,9 @@ class NotificationManager: @unchecked Sendable {
|
|||||||
|
|
||||||
let content = UNMutableNotificationContent()
|
let content = UNMutableNotificationContent()
|
||||||
content.title = "\(scoringTeam.abbrev) Goal!"
|
content.title = "\(scoringTeam.abbrev) Goal!"
|
||||||
|
if let scorer = scorer {
|
||||||
|
content.subtitle = scorer.displayLine
|
||||||
|
}
|
||||||
content.body = "\(game.awayTeam.abbrev) \(awayScore) : \(game.homeTeam.abbrev) \(homeScore)"
|
content.body = "\(game.awayTeam.abbrev) \(awayScore) : \(game.homeTeam.abbrev) \(homeScore)"
|
||||||
content.sound = UNNotificationSound(named: UNNotificationSoundName("Glass"))
|
content.sound = UNNotificationSound(named: UNNotificationSoundName("Glass"))
|
||||||
content.interruptionLevel = .active
|
content.interruptionLevel = .active
|
||||||
|
|||||||
@@ -39,15 +39,23 @@ final class StatusItemManager: @unchecked Sendable {
|
|||||||
|
|
||||||
guard let baseImage = NSImage(named: NSImage.Name("NHLShield")) else {
|
guard let baseImage = NSImage(named: NSImage.Name("NHLShield")) else {
|
||||||
button.title = "NHL"
|
button.title = "NHL"
|
||||||
|
button.image = nil
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let pointSize = button.frame.size.height > 0 ? button.frame.size.height : 22
|
let height = button.frame.size.height > 0 ? button.frame.size.height : 22
|
||||||
let resizedImage = NSImage(size: NSSize(width: pointSize, height: pointSize))
|
|
||||||
|
// 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()
|
resizedImage.lockFocus()
|
||||||
baseImage.draw(
|
baseImage.draw(
|
||||||
in: NSRect(x: 0, y: 0, width: pointSize, height: pointSize),
|
in: NSRect(x: 0, y: 0, width: width, height: height),
|
||||||
from: NSRect(x: 0, y: 0, width: baseImage.size.width, height: baseImage.size.height),
|
from: NSRect(x: 0, y: 0, width: srcW, height: srcH),
|
||||||
operation: .copy,
|
operation: .copy,
|
||||||
fraction: 1.0
|
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 updateStatusText(_ text: String) {
|
||||||
func updateGameCounts(_ gameDays: [Scoreboard.GameDay]) {
|
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
guard let button = self.statusItem?.button else { return }
|
guard let button = self.statusItem?.button else { return }
|
||||||
if gameDays.isEmpty {
|
button.title = text.isEmpty ? "" : " \(text)"
|
||||||
button.title = ""
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let counts = gameDays.map { "\($0.games.count)" }.joined(separator: "/")
|
|
||||||
button.title = " \(counts)"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -73,30 +73,58 @@ struct Scoreboard: Codable {
|
|||||||
"https://videocast.nhl.com/game/\(id)/usnded?autoplay=true"
|
"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 {
|
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 {
|
var menuTitle: String {
|
||||||
let state = parsedGameState
|
let state = parsedGameState
|
||||||
let matchup = "\(awayTeam.abbrev) @ \(homeTeam.abbrev)"
|
let matchup = "\(awayTeam.abbrev) @ \(homeTeam.abbrev)"
|
||||||
|
|
||||||
if state.isFuture {
|
if state.isFuture {
|
||||||
let isToday = gameDate == Date.todayET
|
return "\(matchup) \(startTimeET)"
|
||||||
let prefix = isToday ? "Today @ " : ""
|
|
||||||
return "\(matchup) \(prefix)\(startTimeET)"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Has scores
|
let aScore = String(format: "%2d", awayTeam.score ?? 0)
|
||||||
let score = "\(awayTeam.score ?? 0):\(homeTeam.score ?? 0)"
|
let hScore = String(format: "%-2d", homeTeam.score ?? 0)
|
||||||
return "\(matchup) \(score) (\(gameState))"
|
return "\(matchup) \(aScore):\(hScore) \(startTimeET)"
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether this game involves a specific team
|
/// Sequential game number encoded in the last 4 digits of `id`.
|
||||||
func involves(team abbrev: String) -> Bool {
|
/// Regular season: 1…~1312. Playoffs: 111–417 (`RSG` round/series/game).
|
||||||
awayTeam.abbrev == abbrev || homeTeam.abbrev == abbrev
|
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)!)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -22,16 +22,29 @@ class MainService: @unchecked Sendable {
|
|||||||
|
|
||||||
private var pollingTimer: Timer?
|
private var pollingTimer: Timer?
|
||||||
private var scoreboardApi: ApiService?
|
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)
|
/// All game days from the API (full window: yesterday/today/tomorrow)
|
||||||
private var allGamesByDate: [Scoreboard.GameDay] = []
|
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)
|
/// Previous game snapshots for detecting state/score changes (keyed by game ID)
|
||||||
private var previousGameStates: [Int: GameSnapshot] = [:]
|
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
|
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 {
|
struct GameSnapshot {
|
||||||
let gameState: String
|
let gameState: String
|
||||||
let awayScore: Int?
|
let awayScore: Int?
|
||||||
@@ -44,28 +57,56 @@ class MainService: @unchecked Sendable {
|
|||||||
return allGamesByDate.filter { includedDates.contains($0.date) }
|
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
|
/// Whether any game across all days is currently live
|
||||||
var anyGameLive: Bool {
|
var anyGameLive: Bool {
|
||||||
allGamesByDate.flatMap(\.games).contains { game in
|
allGamesByDate.flatMap(\.games).contains { $0.parsedGameState.isLive }
|
||||||
game.parsedGameState.isLive
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether any game is in pre-game state
|
/// Whether any game is in pre-game state
|
||||||
var anyGamePre: Bool {
|
var anyGamePre: Bool {
|
||||||
allGamesByDate.flatMap(\.games).contains { game in
|
allGamesByDate.flatMap(\.games).contains { $0.parsedGameState == .pre }
|
||||||
game.parsedGameState == .pre
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether any game today is scheduled (future)
|
/// Whether any game today is scheduled (future)
|
||||||
var anyGameToday: Bool {
|
var anyGameToday: Bool {
|
||||||
let today = Date.todayET
|
let today = Date.todayET
|
||||||
return allGamesByDate.contains { gd in
|
return allGamesByDate.contains { $0.date == today && !$0.games.isEmpty }
|
||||||
gd.date == today && !gd.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
|
/// The best polling interval based on current game states
|
||||||
var bestPollingInterval: PollingInterval {
|
var bestPollingInterval: PollingInterval {
|
||||||
if anyGameLive { return .liveGame }
|
if anyGameLive { return .liveGame }
|
||||||
@@ -86,6 +127,7 @@ class MainService: @unchecked Sendable {
|
|||||||
self.initApis()
|
self.initApis()
|
||||||
self.reschedulePollingTimer(.bootstrap)
|
self.reschedulePollingTimer(.bootstrap)
|
||||||
await self.fetchScoreboard()
|
await self.fetchScoreboard()
|
||||||
|
await self.fetchStandings()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -99,24 +141,18 @@ class MainService: @unchecked Sendable {
|
|||||||
do {
|
do {
|
||||||
let scoreboard = try JSONDecoder().decode(Scoreboard.self, from: data)
|
let scoreboard = try JSONDecoder().decode(Scoreboard.self, from: data)
|
||||||
|
|
||||||
// Filter to yesterday/today/tomorrow window
|
|
||||||
let yesterday = Date.yesterdayET
|
let yesterday = Date.yesterdayET
|
||||||
let today = Date.todayET
|
let today = Date.todayET
|
||||||
let tomorrow = Date.tomorrowET
|
let tomorrow = Date.tomorrowET
|
||||||
let windowDates = Set([yesterday, today, tomorrow])
|
let windowDates = Set([yesterday, today, tomorrow])
|
||||||
|
|
||||||
let filtered = scoreboard.gamesByDate.filter { gd in
|
let filtered = scoreboard.gamesByDate.filter { windowDates.contains($0.date) }
|
||||||
windowDates.contains(gd.date)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Detect state and score changes before updating
|
|
||||||
if !self.isFirstFetch {
|
if !self.isFirstFetch {
|
||||||
self.detectChanges(in: filtered)
|
self.detectChanges(in: filtered)
|
||||||
}
|
}
|
||||||
|
|
||||||
self.allGamesByDate = filtered
|
self.allGamesByDate = filtered
|
||||||
|
|
||||||
// Update snapshots for next comparison
|
|
||||||
self.updateSnapshots(from: filtered)
|
self.updateSnapshots(from: filtered)
|
||||||
|
|
||||||
if self.isFirstFetch {
|
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: ", "))")
|
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 interval = self.bestPollingInterval
|
||||||
|
let shouldForceBracket = self.hadPlayoffFinalTransition
|
||||||
|
self.hadPlayoffFinalTransition = false
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
self.reschedulePollingTimer(interval)
|
self.reschedulePollingTimer(interval)
|
||||||
|
await self.refreshBracketIfNeeded(from: filtered, force: shouldForceBracket)
|
||||||
}
|
}
|
||||||
|
|
||||||
self.statusItemManager.updateGameCounts(self.gamesByDate)
|
self.updateUI()
|
||||||
self.menuManager.scoreboardChanged()
|
|
||||||
} catch {
|
} catch {
|
||||||
self.logger.error("Failed to decode scoreboard: \(error.localizedDescription)")
|
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
|
// MARK: - Change Detection
|
||||||
@@ -149,29 +205,70 @@ class MainService: @unchecked Sendable {
|
|||||||
let previousState = GameState(rawValue: previous.gameState)
|
let previousState = GameState(rawValue: previous.gameState)
|
||||||
let currentState = game.parsedGameState
|
let currentState = game.parsedGameState
|
||||||
|
|
||||||
// Game started: FUT/PRE → LIVE
|
|
||||||
if let prevState = previousState, prevState.isFuture, currentState.isLive {
|
if let prevState = previousState, prevState.isFuture, currentState.isLive {
|
||||||
logger.info("Game \(game.id) started: \(game.awayTeam.abbrev) @ \(game.homeTeam.abbrev)")
|
logger.info("Game \(game.id) started: \(game.awayTeam.abbrev) @ \(game.homeTeam.abbrev)")
|
||||||
notificationManager.notifyGameStarted(game)
|
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,
|
if let prevAway = previous.awayScore, let prevHome = previous.homeScore,
|
||||||
let curAway = game.awayTeam.score, let curHome = game.homeTeam.score {
|
let curAway = game.awayTeam.score, let curHome = game.homeTeam.score {
|
||||||
|
|
||||||
if curAway > prevAway {
|
if curAway > prevAway {
|
||||||
logger.info("Goal! \(game.awayTeam.abbrev) scored in game \(game.id): \(curAway):\(curHome)")
|
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 {
|
if curHome > prevHome {
|
||||||
logger.info("Goal! \(game.homeTeam.abbrev) scored in game \(game.id): \(curAway):\(curHome)")
|
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]) {
|
private func updateSnapshots(from gameDays: [Scoreboard.GameDay]) {
|
||||||
for gameDay in gameDays {
|
for gameDay in gameDays {
|
||||||
for game in gameDay.games {
|
for game in gameDay.games {
|
||||||
@@ -220,7 +317,44 @@ class MainService: @unchecked Sendable {
|
|||||||
await scoreboardApi?.fetch()
|
await scoreboardApi?.fetch()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func fetchStandings() async {
|
||||||
|
await standingsApi?.fetch()
|
||||||
|
}
|
||||||
|
|
||||||
func fetchAll() async {
|
func fetchAll() async {
|
||||||
await fetchScoreboard()
|
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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ A macOS menu bar app for NHL game situational awareness.
|
|||||||
|
|
||||||
- NHL shield icon in the menu bar with game count
|
- NHL shield icon in the menu bar with game count
|
||||||
- Shows games from yesterday, today, and tomorrow grouped by date (configurable)
|
- 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`
|
- 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
|
- Click a game to open NHL GameCenter; option-click for NHL Videocast
|
||||||
- Goal scored notifications with scoring team logo
|
- Goal scored notifications with scoring team logo
|
||||||
|
|||||||
@@ -27,12 +27,14 @@ targets:
|
|||||||
CURRENT_PROJECT_VERSION: "1"
|
CURRENT_PROJECT_VERSION: "1"
|
||||||
INFOPLIST_FILE: IceGlass/Info.plist
|
INFOPLIST_FILE: IceGlass/Info.plist
|
||||||
CODE_SIGN_ENTITLEMENTS: IceGlass.entitlements
|
CODE_SIGN_ENTITLEMENTS: IceGlass.entitlements
|
||||||
|
DEVELOPMENT_TEAM: C32Z8JNLG6
|
||||||
SWIFT_VERSION: "6.0"
|
SWIFT_VERSION: "6.0"
|
||||||
MACOSX_DEPLOYMENT_TARGET: "13.0"
|
MACOSX_DEPLOYMENT_TARGET: "13.0"
|
||||||
PRODUCT_NAME: IceGlass
|
PRODUCT_NAME: IceGlass
|
||||||
COMBINE_HIDPI_IMAGES: true
|
COMBINE_HIDPI_IMAGES: true
|
||||||
SWIFT_STRICT_CONCURRENCY: complete
|
SWIFT_STRICT_CONCURRENCY: complete
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING: false
|
ENABLE_USER_SCRIPT_SANDBOXING: false
|
||||||
|
ENABLE_HARDENED_RUNTIME: true
|
||||||
configs:
|
configs:
|
||||||
Debug:
|
Debug:
|
||||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS: DEBUG
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS: DEBUG
|
||||||
|
|||||||