Add game-ended notification

Fires the first time a game transitions from non-over to any over-state
(OVER/FINAL/OFF), so the notification lands when the clock runs out
rather than hours later when NHL statisticians stamp OFF. Shows the
winning team's logo with a "{TEAM} wins" title and final score body.

Deduped by game ID via gameEndsSent; cleared on resetForNewDay.
Dev menu: "Test Game Ended Notification" for manual trigger.
This commit is contained in:
2026-04-19 21:38:08 -04:00
parent 89060d7177
commit 18c4ef64d6
3 changed files with 77 additions and 0 deletions
+10
View File
@@ -220,6 +220,9 @@ class MenuManager: @unchecked Sendable {
devSubmenu.addItem( 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)
) )
devSubmenu.addItem(
NSMenuItem(title: "Test Game Ended Notification", action: #selector(triggerTestGameEnded), keyEquivalent: "").withTarget(self)
)
devSubmenu.addItem( devSubmenu.addItem(
NSMenuItem(title: "Thumbnail Size Test", action: #selector(triggerThumbnailSizeTest), keyEquivalent: "").withTarget(self) NSMenuItem(title: "Thumbnail Size Test", action: #selector(triggerThumbnailSizeTest), keyEquivalent: "").withTarget(self)
) )
@@ -308,6 +311,13 @@ class MenuManager: @unchecked Sendable {
} }
} }
@objc private func triggerTestGameEnded() {
let notificationManager = NotificationManager.shared
if let game = mainService.gamesByDate.flatMap(\.games).first {
notificationManager.notifyGameEnded(game, bypassDedup: true)
}
}
@objc private func triggerThumbnailSizeTest() { @objc private func triggerThumbnailSizeTest() {
NotificationManager.shared.sendThumbnailSizeTest() NotificationManager.shared.sendThumbnailSizeTest()
} }
@@ -25,6 +25,9 @@ class NotificationManager: @unchecked Sendable {
/// Track which score changes we've already notified about (gameId-awayScore-homeScore) /// Track which score changes we've already notified about (gameId-awayScore-homeScore)
private var scoreChangesSent = Set<String>() private var scoreChangesSent = Set<String>()
/// Track which game endings we've already notified about
private var gameEndsSent = Set<Int>()
private init() { private init() {
logger.info("Initializing") logger.info("Initializing")
requestNotificationPermissions() requestNotificationPermissions()
@@ -143,6 +146,63 @@ class NotificationManager: @unchecked Sendable {
} }
} }
// MARK: - Game Ended
func notifyGameEnded(_ game: Scoreboard.Game, bypassDedup: Bool = false) {
if !bypassDedup {
guard !gameEndsSent.contains(game.id) else { return }
gameEndsSent.insert(game.id)
}
let awayScore = game.awayTeam.score ?? 0
let homeScore = game.homeTeam.score ?? 0
let winner: Scoreboard.Game.Team
let loser: Scoreboard.Game.Team
if awayScore > homeScore {
winner = game.awayTeam
loser = game.homeTeam
} else if homeScore > awayScore {
winner = game.homeTeam
loser = game.awayTeam
} else {
// Tie shouldn't happen in NHL, but handle gracefully
winner = game.homeTeam
loser = game.awayTeam
}
let winnerScore = max(awayScore, homeScore)
let loserScore = min(awayScore, homeScore)
let content = UNMutableNotificationContent()
content.title = "\(winner.abbrev) wins"
content.body = "\(winner.abbrev) \(winnerScore)\(loser.abbrev) \(loserScore)"
content.sound = .default
content.interruptionLevel = .active
content.userInfo = ["url": game.gameCenterUrl]
// Attach winning team's logo
if let attachment = teamLogoAttachment(for: winner.abbrev) {
content.attachments = [attachment]
}
let identifier = bypassDedup
? "game-end-test-\(UUID().uuidString)"
: "game-end-\(game.id)"
let request = UNNotificationRequest(
identifier: identifier,
content: content,
trigger: UNTimeIntervalNotificationTrigger(timeInterval: 0.1, repeats: false)
)
UNUserNotificationCenter.current().add(request) { [weak self] error in
if let error = error {
self?.logger.error("Error sending game end notification: \(error.localizedDescription)")
} else {
self?.logger.info("Game end notification sent: \(winner.abbrev) \(winnerScore)\(loser.abbrev) \(loserScore)")
}
}
}
// MARK: - Team Logo Attachment // MARK: - Team Logo Attachment
/// Creates a UNNotificationAttachment from the bundled team logo PNG. /// Creates a UNNotificationAttachment from the bundled team logo PNG.
@@ -185,6 +245,7 @@ class NotificationManager: @unchecked Sendable {
logger.debug("Resetting notification tracking for new day") logger.debug("Resetting notification tracking for new day")
gameStartsSent.removeAll() gameStartsSent.removeAll()
scoreChangesSent.removeAll() scoreChangesSent.removeAll()
gameEndsSent.removeAll()
} }
#if DEBUG #if DEBUG
+6
View File
@@ -219,6 +219,12 @@ class MainService: @unchecked Sendable {
notificationManager.notifyGameStarted(game) notificationManager.notifyGameStarted(game)
} }
// Game ended: transition to any over-state (OVER/FINAL/OFF)
if let prevState = previousState, !prevState.isOver, currentState.isOver {
logger.info("Game \(game.id) ended: \(game.awayTeam.abbrev) \(game.awayTeam.score ?? 0)\(game.homeTeam.abbrev) \(game.homeTeam.score ?? 0)")
notificationManager.notifyGameEnded(game)
}
if game.gameType == 3, if game.gameType == 3,
let prevState = previousState, !prevState.isOver, currentState.isOver { let prevState = previousState, !prevState.isOver, currentState.isOver {
hadPlayoffFinalTransition = true hadPlayoffFinalTransition = true