diff --git a/CHANGELOG.md b/CHANGELOG.md index c65d095..04ffca2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## May 2026 + +- Mark the result of a finished playoff series by striking through the eliminated team's tricode, and drop the redundant "(Final … wins)" tag on completed earlier rounds (both macOS menu and iPhone) +- Playoff section now lists every round played so far (ROUND 1 → current), in ascending order, instead of just the current round — on both macOS menu and iPhone +- Re-fetch the playoff bracket whenever a finished game in the window implies more completed games than the cached bracket records, so the ROUND series score and round advancement no longer get stuck on stale data after a cold launch or the NHL bracket endpoint's post-final update lag +- Tolerate optional `gameCenterLink` and `startTimeUTC` on TBD playoff matchups so the scoreboard decode no longer aborts (was leaving in-memory state frozen and showing stale yesterday rows) +- Reject API state regressions: never let a known game downgrade along the FUT→PRE→LIVE→CRIT→OVER→FINAL→OFF progression, so a brief `/scoreboard/now` glitch can't turn a finished game back into "-:-" on iOS cold launch + ## April 2026 - New iPhone target (`IceGlass-iOS`): single-page SwiftUI app mirroring the macOS menu's playoff round + yesterday/today/tomorrow content, with a gear-icon settings sheet (display option + IndieAbout) diff --git a/IceGlass-iOS/ViewModel/ScoreboardViewModel.swift b/IceGlass-iOS/ViewModel/ScoreboardViewModel.swift index 9f8acfe..e727229 100644 --- a/IceGlass-iOS/ViewModel/ScoreboardViewModel.swift +++ b/IceGlass-iOS/ViewModel/ScoreboardViewModel.swift @@ -40,14 +40,9 @@ final class ScoreboardViewModel { return MainService.shared.gamesByDate } - var currentRoundSeriesItems: [MainService.RoundSeriesItem] { + var roundSeriesGroups: [MainService.RoundGroup] { _ = revision - return MainService.shared.currentRoundSeriesItems - } - - var currentRoundNumber: Int? { - _ = revision - return MainService.shared.bracket?.currentRound + return MainService.shared.roundSeriesGroups } /// Bridge object that forwards MainService callbacks back to this view model. diff --git a/IceGlass-iOS/Views/GameRow.swift b/IceGlass-iOS/Views/GameRow.swift index 81d71d9..6311e74 100644 --- a/IceGlass-iOS/Views/GameRow.swift +++ b/IceGlass-iOS/Views/GameRow.swift @@ -106,7 +106,8 @@ struct GameRow: View { } private func open() { - guard let url = URL(string: game.gameCenterUrl) else { return } + guard let urlString = game.gameCenterUrl, + let url = URL(string: urlString) else { return } UIApplication.shared.open(url) } } diff --git a/IceGlass-iOS/Views/MainView.swift b/IceGlass-iOS/Views/MainView.swift index ffbbf70..205b37c 100644 --- a/IceGlass-iOS/Views/MainView.swift +++ b/IceGlass-iOS/Views/MainView.swift @@ -21,17 +21,21 @@ struct MainView: View { ) .padding(.horizontal) - if !viewModel.currentRoundSeriesItems.isEmpty, - let round = viewModel.currentRoundNumber { - PlayoffRoundSection( - round: round, - items: viewModel.currentRoundSeriesItems - ) - .padding(.horizontal) + let roundGroups = viewModel.roundSeriesGroups + let latestRound = roundGroups.last?.round + ForEach(roundGroups, id: \.round) { group in + if !group.items.isEmpty { + PlayoffRoundSection( + round: group.round, + items: group.items, + showStatus: group.round == latestRound + ) + .padding(.horizontal) + } } let gameDays = viewModel.gamesByDate - if gameDays.isEmpty && viewModel.currentRoundSeriesItems.isEmpty { + if gameDays.isEmpty && roundGroups.isEmpty { emptyState .padding(.horizontal) .padding(.top, 40) diff --git a/IceGlass-iOS/Views/PlayoffRoundSection.swift b/IceGlass-iOS/Views/PlayoffRoundSection.swift index c6dde5e..920421d 100644 --- a/IceGlass-iOS/Views/PlayoffRoundSection.swift +++ b/IceGlass-iOS/Views/PlayoffRoundSection.swift @@ -10,6 +10,7 @@ import SwiftUI struct PlayoffRoundSection: View { let round: Int let items: [MainService.RoundSeriesItem] + var showStatus: Bool = true var body: some View { VStack(alignment: .leading, spacing: 8) { @@ -20,7 +21,7 @@ struct PlayoffRoundSection: View { VStack(spacing: 0) { ForEach(items, id: \.series.seriesLetter) { item in - SeriesRow(item: item) + SeriesRow(item: item, showStatus: showStatus) if item.series.seriesLetter != items.last?.series.seriesLetter { Divider() } diff --git a/IceGlass-iOS/Views/SeriesRow.swift b/IceGlass-iOS/Views/SeriesRow.swift index 9cd015b..120eede 100644 --- a/IceGlass-iOS/Views/SeriesRow.swift +++ b/IceGlass-iOS/Views/SeriesRow.swift @@ -9,6 +9,9 @@ import SwiftUI struct SeriesRow: View { let item: MainService.RoundSeriesItem + /// Completed earlier rounds hide the status/next-game column; the struck-out + /// loser already conveys the result. + var showStatus: Bool = true private static let logoSize: CGFloat = 40 @@ -17,7 +20,9 @@ struct SeriesRow: View { HStack(alignment: .center, spacing: 10) { matchupBlock Spacer(minLength: 8) - rightContent + if showStatus { + rightContent + } } .padding(.horizontal, 14) .padding(.vertical, 8) @@ -34,6 +39,7 @@ struct SeriesRow: View { Text(bottom) .font(.title3.monospaced()) .fontWeight(.semibold) + .strikethrough(item.series.loser == bottom) .foregroundStyle(.primary) .lineLimit(1) .fixedSize() @@ -47,6 +53,7 @@ struct SeriesRow: View { Text(top) .font(.title3.monospaced()) .fontWeight(.semibold) + .strikethrough(item.series.loser == top) .foregroundStyle(.primary) .lineLimit(1) .fixedSize() diff --git a/IceGlass/Managers/MenuManager.swift b/IceGlass/Managers/MenuManager.swift index d642056..f0aae52 100644 --- a/IceGlass/Managers/MenuManager.swift +++ b/IceGlass/Managers/MenuManager.swift @@ -91,9 +91,10 @@ 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 roundGroups = mainService.roundSeriesGroups + let latestRound = roundGroups.last?.round + for group in roundGroups where !group.items.isEmpty { + let headerText = "ROUND \(group.round)" let headerItem = NSMenuItem(title: headerText, action: nil, keyEquivalent: "") headerItem.attributedTitle = NSAttributedString( string: headerText, @@ -101,8 +102,8 @@ class MenuManager: @unchecked Sendable { ) menu.addItem(headerItem) - for item in roundItems { - menu.addItem(createRoundSeriesItem(for: item, font: monoFont)) + for item in group.items { + menu.addItem(createRoundSeriesItem(for: item, showStatus: group.round == latestRound, font: monoFont)) } menu.addItem(NSMenuItem.separator()) @@ -254,7 +255,8 @@ class MenuManager: @unchecked Sendable { @objc private func openGame(_ sender: NSMenuItem) { guard let game = sender.representedObject as? Scoreboard.Game, - let url = URL(string: game.gameCenterUrl) else { return } + let urlString = game.gameCenterUrl, + let url = URL(string: urlString) else { return } NSWorkspace.shared.open(url) } @@ -352,32 +354,50 @@ class MenuManager: @unchecked Sendable { return [item, altItem] } - private func createRoundSeriesItem(for roundItem: MainService.RoundSeriesItem, font: NSFont) -> NSMenuItem { + private func createRoundSeriesItem(for roundItem: MainService.RoundSeriesItem, showStatus: Bool, 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?.nextGameLabel ?? "" + // Completed earlier rounds show just the matchup and score — the struck-out + // loser already conveys the result, so the "(Final … wins)" tag is dropped. + let title: String + if showStatus { + 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?.nextGameLabel ?? "" + } else { + statusTag = "" + trailing = "" + } + let tagColumn = statusTag.padding(toLength: 20, withPad: " ", startingAt: 0) + title = " \(matchup) \(score) \(tagColumn)\(trailing)" } else { - statusTag = "" - trailing = "" + title = " \(matchup) \(score)" } - 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]) + + let attributed = NSMutableAttributedString(string: title, attributes: [.font: font]) + // Strike through the eliminated team's tricode, searching only within the + // matchup region so the same abbrev inside the "(Final … wins)" tag stays plain. + if let loser = series.loser { + let matchupRange = NSRange(location: 2, length: (matchup as NSString).length) + let loserRange = (title as NSString).range(of: loser, options: [], range: matchupRange) + if loserRange.location != NSNotFound { + attributed.addAttribute(.strikethroughStyle, value: NSUnderlineStyle.single.rawValue, range: loserRange) + } + } + item.attributedTitle = attributed return item } diff --git a/IceGlass/Managers/NotificationManager.swift b/IceGlass/Managers/NotificationManager.swift index c73e1b1..cdeb342 100644 --- a/IceGlass/Managers/NotificationManager.swift +++ b/IceGlass/Managers/NotificationManager.swift @@ -69,7 +69,9 @@ class NotificationManager: @unchecked Sendable { content.body = "\(game.awayTeam.abbrev) @ \(game.homeTeam.abbrev)" content.sound = .default content.interruptionLevel = .active - content.userInfo = ["url": game.gameCenterUrl] + if let url = game.gameCenterUrl { + content.userInfo = ["url": url] + } // Attach away team logo (visiting team at the other team's arena) if let attachment = teamLogoAttachment(for: game.awayTeam.abbrev) { @@ -120,7 +122,9 @@ class NotificationManager: @unchecked Sendable { content.body = "\(game.awayTeam.abbrev) \(awayScore) : \(game.homeTeam.abbrev) \(homeScore)" content.sound = UNNotificationSound(named: UNNotificationSoundName("Glass")) content.interruptionLevel = .active - content.userInfo = ["url": game.gameCenterUrl] + if let url = game.gameCenterUrl { + content.userInfo = ["url": url] + } // Attach scoring team's logo if let attachment = teamLogoAttachment(for: scoringTeam.abbrev) { @@ -177,7 +181,9 @@ class NotificationManager: @unchecked Sendable { content.body = "\(winner.abbrev) \(winnerScore) — \(loser.abbrev) \(loserScore)" content.sound = .default content.interruptionLevel = .active - content.userInfo = ["url": game.gameCenterUrl] + if let url = game.gameCenterUrl { + content.userInfo = ["url": url] + } // Attach winning team's logo if let attachment = teamLogoAttachment(for: winner.abbrev) { diff --git a/README.md b/README.md index 2a41d6e..9be6202 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ NHL game situational awareness for macOS (menu bar) and iPhone (single-page app) - NHL shield icon in the menu bar with game count - 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 +- During playoffs, ROUND sections list every round played so far (Round 1 through the current round) and each round's 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` - Click a game to open NHL GameCenter; option-click for NHL Videocast - Goal scored notifications with scoring team logo diff --git a/Shared/Models/BracketModel.swift b/Shared/Models/BracketModel.swift index c0021ed..bca59cd 100644 --- a/Shared/Models/BracketModel.swift +++ b/Shared/Models/BracketModel.swift @@ -40,6 +40,12 @@ struct PlayoffBracket: Codable { return topSeedWins == 4 ? topSeedTeam?.abbrev : bottomSeedTeam?.abbrev } + /// Abbrev of the eliminated side, nil if the series is ongoing. + var loser: String? { + guard isOver else { return nil } + return topSeedWins == 4 ? bottomSeedTeam?.abbrev : topSeedTeam?.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)" } @@ -63,7 +69,17 @@ struct PlayoffBracket: Codable { /// All matched series in the current round. var currentRoundSeries: [Series] { guard let round = currentRound else { return [] } - return series + return matchedSeries(inRound: round) + } + + /// Rounds that have at least one matched series, ascending (1…current). + var activeRounds: [Int] { + Set(series.filter(\.isMatched).map(\.playoffRound)).sorted() + } + + /// All matched series in the given round, sorted by series letter. + func matchedSeries(inRound round: Int) -> [Series] { + series .filter { $0.playoffRound == round && $0.isMatched } .sorted { $0.seriesLetter < $1.seriesLetter } } diff --git a/Shared/Models/GameState.swift b/Shared/Models/GameState.swift index 1669e19..d7b6fa8 100644 --- a/Shared/Models/GameState.swift +++ b/Shared/Models/GameState.swift @@ -42,6 +42,27 @@ enum GameState: String, Codable { } } + /// Monotonic ordering along the FUT→PRE→LIVE→CRIT→OVER→FINAL→OFF progression. + /// Used to detect — and reject — API regressions that briefly downgrade a + /// finished game back to FUT during the daily focusedDate rollover. + var progressionRank: Int { + switch self { + case .future: return 0 + case .pre: return 1 + case .live: return 2 + case .crit: return 3 + case .over: return 4 + case .final_: return 5 + case .official: return 6 + } + } + + /// Same ranking, addressable by the raw API string. Unknown states get -1 + /// so any known state replaces them. + static func progressionRank(of rawState: String) -> Int { + GameState(rawValue: rawState)?.progressionRank ?? -1 + } + var pollingInterval: PollingInterval { switch self { case .future: diff --git a/Shared/Models/ScoreboardModel.swift b/Shared/Models/ScoreboardModel.swift index f290599..e5941a4 100644 --- a/Shared/Models/ScoreboardModel.swift +++ b/Shared/Models/ScoreboardModel.swift @@ -26,8 +26,10 @@ struct Scoreboard: Codable { let season: Int let gameType: Int let gameDate: String - let gameCenterLink: String - let startTimeUTC: String + /// Missing on TBD playoff matchups (opponent not yet decided). + let gameCenterLink: String? + /// Missing on TBD playoff matchups not yet scheduled. + let startTimeUTC: String? let gameState: String let gameScheduleState: String let awayTeam: Team @@ -62,19 +64,23 @@ struct Scoreboard: Codable { } var date: Date { - ISO8601DateFormatter().date(from: startTimeUTC) ?? .now + guard let startTimeUTC else { return .distantFuture } + return ISO8601DateFormatter().date(from: startTimeUTC) ?? .now } - var gameCenterUrl: String { - "https://www.nhl.com\(gameCenterLink)" + var gameCenterUrl: String? { + guard let gameCenterLink else { return nil } + return "https://www.nhl.com\(gameCenterLink)" } var videocastUrl: String { "https://videocast.nhl.com/game/\(id)/usnded?autoplay=true" } - /// Time string in ET, right-aligned to 8 chars (e.g. " 9:30 PM", "10:00 PM") + /// Time string in ET, right-aligned to 8 chars (e.g. " 9:30 PM", "10:00 PM"). + /// Empty when the game has no scheduled start (TBD opponent). var startTimeET: String { + guard startTimeUTC != nil else { return "" } let raw = date.formatDateET(format: "h:mm a") return raw.count < 8 ? String(repeating: " ", count: 8 - raw.count) + raw diff --git a/Shared/Services/MainService.swift b/Shared/Services/MainService.swift index a6fa211..26d08f4 100644 --- a/Shared/Services/MainService.swift +++ b/Shared/Services/MainService.swift @@ -127,12 +127,20 @@ class MainService: @unchecked Sendable { return allGamesByDate.contains { $0.date == today && !$0.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] { + /// Every active playoff round paired with its matched series, ascending by + /// round (1…current). Each series carries its next scheduled game from the + /// fetched window (if any). Empty during the regular season. + var roundSeriesGroups: [RoundGroup] { guard let bracket = bracket else { return [] } + return bracket.activeRounds.map { round in + RoundGroup(round: round, items: roundSeriesItems(for: bracket.matchedSeries(inRound: round))) + } + } + + /// Pairs each series with its next unplayed game from the fetched window. + private func roundSeriesItems(for seriesList: [PlayoffBracket.Series]) -> [RoundSeriesItem] { let windowGames = allGamesByDate.flatMap(\.games) - return bracket.currentRoundSeries.map { series in + return seriesList.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 } @@ -140,6 +148,11 @@ class MainService: @unchecked Sendable { } } + struct RoundGroup { + let round: Int + let items: [RoundSeriesItem] + } + struct RoundSeriesItem { let series: PlayoffBracket.Series let nextGame: Scoreboard.Game? @@ -181,8 +194,8 @@ class MainService: @unchecked Sendable { let tomorrow = Date.tomorrowET let windowDates = Set([yesterday, today, tomorrow]) let filtered = scoreboard.gamesByDate.filter { windowDates.contains($0.date) } - self.allGamesByDate = filtered - self.updateSnapshots(from: filtered) + self.allGamesByDate = mergeWithMonotonicState(filtered) + self.updateSnapshots(from: self.allGamesByDate) } self.standings = snapshot.standings self.bracket = snapshot.bracket @@ -225,13 +238,14 @@ class MainService: @unchecked Sendable { let windowDates = Set([yesterday, today, tomorrow]) let filtered = scoreboard.gamesByDate.filter { windowDates.contains($0.date) } + let merged = self.mergeWithMonotonicState(filtered) if !self.isFirstFetch { - self.detectChanges(in: filtered) + self.detectChanges(in: merged) } - self.allGamesByDate = filtered - self.updateSnapshots(from: filtered) + self.allGamesByDate = merged + self.updateSnapshots(from: merged) self.lastUpdated = Date() if self.isFirstFetch { @@ -368,6 +382,29 @@ class MainService: @unchecked Sendable { } } + /// Reject API responses that downgrade a known game to an earlier state in + /// the FUT→PRE→LIVE→CRIT→OVER→FINAL→OFF progression. The NHL `/scoreboard/now` + /// CDN occasionally serves yesterday's finished games as `FUT` during the + /// daily focusedDate rollover; without this guard, iOS opens cold in the + /// morning, sees that one bad response, and shows `-:-` until the API + /// self-corrects. macOS polls through it but iOS only gets one chance. + private func mergeWithMonotonicState(_ incoming: [Scoreboard.GameDay]) -> [Scoreboard.GameDay] { + let previousById = Dictionary( + uniqueKeysWithValues: allGamesByDate.flatMap(\.games).map { ($0.id, $0) } + ) + return incoming.map { day in + let games = day.games.map { newGame -> Scoreboard.Game in + guard let prev = previousById[newGame.id] else { return newGame } + let prevRank = GameState.progressionRank(of: prev.gameState) + let newRank = GameState.progressionRank(of: newGame.gameState) + guard prevRank > newRank else { return newGame } + logger.warning("Rejecting state regression for game \(newGame.id) (\(newGame.awayTeam.abbrev)@\(newGame.homeTeam.abbrev)): API \(newGame.gameState) < held \(prev.gameState)") + return prev + } + return Scoreboard.GameDay(date: day.date, games: games) + } + } + private func updateSnapshots(from gameDays: [Scoreboard.GameDay]) { for gameDay in gameDays { for game in gameDay.games { @@ -442,8 +479,8 @@ class MainService: @unchecked Sendable { // 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 { + let playoffGames = gameDays.flatMap(\.games).filter { $0.gameType == 3 } + guard let playoffGame = playoffGames.first else { bracket = nil return } @@ -468,8 +505,26 @@ class MainService: @unchecked Sendable { } } - if bracket == nil || seasonChanged || force { + if bracket == nil || seasonChanged || force || isBracketStale(against: playoffGames) { await bracketApi?.fetch() } } + + /// True when a finished playoff game in the window implies more completed + /// games than the cached bracket has recorded for that series — i.e. the + /// bracket is behind the scoreboard. Catches both a stale cached bracket + /// loaded on cold launch and the lag between a game going final and the NHL + /// bracket endpoint updating its win counts. Self-clears once the bracket + /// catches up, so it stops triggering fetches as soon as the two agree. + private func isBracketStale(against playoffGames: [Scoreboard.Game]) -> Bool { + guard let bracket = bracket else { return false } + for game in playoffGames where game.parsedGameState.isOver { + guard let context = game.playoffContext, + let series = bracket.series(for: game) else { continue } + if series.topSeedWins + series.bottomSeedWins < context.gameInSeries { + return true + } + } + return false + } }