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:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user