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

- Fetch NHL standings and surface league/season game counts in the menu bar
- Prefix regular-season rows with the league-wide game number (from gameId)
- New ROUND section shows each active playoff series (matchup, series score,
  next game number + time) derived from /v1/playoff-bracket; rows always open
  the NHL series page so completed series remain clickable
- Goal notifications include scorer sweater, abbreviated name, and strength
  (PPG/SHG/EN), resolved via /v1/gamecenter/{id}/play-by-play
- Drop the per-team filter submenu and NHLTeam enum
- Regenerate AppIcon with the full 10-size macOS set (alpha preserved) so
  notifications render the app icon correctly; rename the iOS marketing PNG
  to icon-ios-1024.png
- gitignore .claude/ local tooling settings
This commit is contained in:
2026-04-18 21:51:27 -04:00
parent 8f8f8b2755
commit 57358797e1
44 changed files with 596 additions and 286 deletions
+113 -25
View File
@@ -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)"
}
}