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:
@@ -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<String> {
|
||||
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) {
|
||||
|
||||
@@ -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)"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user